# trusty-cage

Isolated Docker-based development environments for AI coding agents. Run Claude Code (or any agent) with full autonomy inside a disposable container — no risk to your host machine, no credentials exposed, no accidental pushes.

## Installation

```bash
pip install trusty-cage
```

Or with [pipx](https://pipx.pypa.io/) for isolated CLI installs:

```bash
pipx install trusty-cage
```

`tc` is available as a shorthand for `trusty-cage` (e.g. `tc create ...`, `tc attach ...`).

## Quick Start

```bash
# One-time setup: create config directory and default .env file
trusty-cage init

# Create an environment from any git repo
trusty-cage create https://github.com/octocat/Hello-World

# You're now inside a tmux session (prefix: Ctrl-a) with:
#   Left pane (60%)  — Neovim at the project root
#   Top-right pane   — Claude Code running with --dangerously-skip-permissions
#   Bottom-right pane — plain shell
#
# If you configured TRUSTY_CAGE_DOTFILES_REPO, your shell config, aliases,
# tmux settings, and Neovim config are already applied — the container
# should feel like your own machine.

# Switch panes with Ctrl-a <arrow>, detach with Ctrl-a d

# When done, export your work back to the host:
trusty-cage export hello-world

# Review and push from the host clone:
cd ~/.trusty-cage/envs/hello-world/repo/
git diff
git add -A && git commit -m "work from trusty-cage"
git push

# Or copy into an existing clone (don't forget the trailing /):
cd ~/projects/hello-world
cp -R ~/.trusty-cage/envs/hello-world/repo/ .
```

## Demo

Here's a real workflow using trusty-cage to build an [Obsidian plugin](https://github.com/areese801/obsidian-todoist) from scratch.

### 1. Give instructions inside the cage

![Inside the cage — giving Claude Code instructions](https://raw.githubusercontent.com/areese801/trusty-cage/main/.images/trusty-cage-inside-instructions.png)

The terminal title bar shows the `tc create` command that built this environment. Inside, Claude Code runs with full autonomy (`bypass permissions on`) in a tmux session alongside Neovim. The container's git repo has **no remotes** — the agent can commit locally but has no way to push anywhere.

### 2. Let the agent work autonomously

![Claude Code working autonomously](https://raw.githubusercontent.com/areese801/trusty-cage/main/.images/trusty-cage-inside-auto-work.png)

Claude Code explored a reference project, designed an architecture, wrote the full plugin (TypeScript, settings UI, API client, parser), and committed everything — all without any human intervention. The agent had full control inside the container: installing packages, creating files, running builds. If anything went wrong, the host machine would be completely unaffected.

### 3. Export and review on the host

![Exporting work back to the host](https://raw.githubusercontent.com/areese801/trusty-cage/main/.images/trusty-cage-outside-export-work.png)

Back on the host, `tc export` copies the container's work into the host clone at `~/.trusty-cage/envs/<name>/repo/`. From there, you review the diff, commit, and push — the human stays in the loop for all git operations that touch a remote.

**To work from the exported repository:**

```bash
cd ~/.trusty-cage/envs/obsidian-todoist/repo/
git diff
git add -A && git commit -m "work from trusty-cage"
git push
```

**To copy exported code into your own cloned repository:**

If you already have the repo cloned elsewhere (e.g. `~/projects/personal/obsidian-todoist`), you can copy the exported files into it instead. Make sure you're `cd`'d into your clone first, and **don't forget the trailing `/`** on the source path — on macOS (BSD `cp`), the trailing `/` copies the _contents_ of the directory rather than the directory itself:

```bash
cd ~/projects/personal/obsidian-todoist
cp -R ~/.trusty-cage/envs/obsidian-todoist/repo/ .
git status   # review what changed
git diff
git add -A && git commit -m "work from trusty-cage"
git push
```

**Linux note:** GNU `cp` ignores the trailing `/` and always copies the directory itself. On Linux, use `cp -RT` or `rsync -a` instead:

```bash
cp -RT ~/.trusty-cage/envs/obsidian-todoist/repo .
rsync -a ~/.trusty-cage/envs/obsidian-todoist/repo/ .
```

> The `Permission denied` errors on `.git/objects/pack` files are expected and harmless — your host `.git/` is preserved and those locked pack files don't need to be overwritten.

## Example: Hello World

```bash
# Create (environment name is derived as lowercase: "hello-world")
trusty-cage create https://github.com/octocat/Hello-World --no-attach

# Verify
trusty-cage list
docker ps -a | grep isolated-dev

# Attach — drops you into tmux inside the container
trusty-cage attach hello-world

# Inside the container:
#   Ctrl-a <arrow>    — switch tmux panes
#   git remote -v     — empty (no remotes, by design)
#   curl example.com  — works (outbound web allowed)
#   Ctrl-a d          — detach

# Export work back to host
trusty-cage export hello-world

# Clean up
trusty-cage destroy hello-world
```

## Example: Local Directory

No remote repo? Use `--dir` to create a cage from any local directory:

```bash
# Create from current directory
trusty-cage create --dir . --name my-project --no-attach

# Or from an explicit path
trusty-cage create --dir ~/projects/my-project --no-attach

# Everything else works the same
trusty-cage attach my-project
trusty-cage export my-project
trusty-cage destroy my-project
```

## Additional Directories

Ship extra local directories into a cage alongside the main project. Each additional directory gets its own Docker volume and host clone, with full export/sync/diff support.

```bash
# Include at create time
tc create --dir ~/projects/main-app --add-dir ~/projects/shared-lib --name my-cage --no-attach

# Or add to an existing cage (recreates the container with a new volume mount)
tc add-dir my-cage ~/projects/shared-lib

# Additional dirs appear in the container's home directory
# /home/trustycage/project       ← main project (always)
# /home/trustycage/shared-lib    ← additional dir

# Export, diff, and sync work with --dir and --all
tc diff my-cage --dir shared-lib           # diff one additional dir
tc diff my-cage --all                      # diff main project + all additional dirs
tc export my-cage --dir shared-lib -y      # export one additional dir
tc export my-cage --all -y                 # export everything
tc sync my-cage --dir shared-lib -y        # push host changes into one dir

# Remove a directory (removes volume and host clone)
tc remove-dir my-cage shared-lib -y

# Destroy cleans up all volumes (main + additional dirs)
tc destroy my-cage -y
```

Directory names are derived from the path basename (lowercased, sanitized). Override with `--name`:

```bash
tc add-dir my-cage ~/projects/My.Library --name mylib
```

The name `project` is reserved for the main project directory.

## Commands

| Command                                                                                               | Description                                                                   |
| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `trusty-cage --version`                                                                               | Show version and exit                                                         |
| `trusty-cage init [--force]`                                                                          | Create config directory and default `.env` file                               |
| `trusty-cage create <url> [--name] [--no-attach] [--auth-mode] [--dockerfile] [--add-dir]`            | Create a new environment from a git repo                                      |
| `trusty-cage create --dir <path> [--name] [--no-attach] [--auth-mode] [--dockerfile] [--add-dir]`     | Create from a local directory (no remote required)                            |
| `trusty-cage attach <name>`                                                                           | Attach to an existing environment                                             |
| `trusty-cage stop <name>`                                                                             | Stop a container (preserves work)                                             |
| `trusty-cage list [--json]`                                                                           | List all environments with status and additional dirs                         |
| `trusty-cage exists <name>`                                                                           | Check if an environment exists (exit code 0/1)                                |
| `trusty-cage add-dir <name> <path> [--name]`                                                          | Add a local directory to an existing cage                                     |
| `trusty-cage remove-dir <name> <dir-name> [-y/--yes]`                                                 | Remove an additional directory from a cage                                    |
| `trusty-cage export <name> [-y/--yes] [--output-dir] [--delete] [--protect] [--dir] [--all]`          | Copy work back to host clone (safe default: no `--delete`)                    |
| `trusty-cage diff <name> [--full] [--output-dir] [--dir] [--all]`                                     | Preview what `export` would change (dry run)                                  |
| `trusty-cage sync <name> [--files] [-y/--yes] [--dir] [--all]`                                        | Push host files into a cage (inverse of export)                               |
| `trusty-cage destroy <name> [-y/--yes] [--keep-host-clone]`                                           | Remove container, volumes, and host clone (pass `--keep-host-clone` to retain)|
| `trusty-cage rebuild-image [--dockerfile]`                                                            | Force rebuild the Docker image                                                |
| `trusty-cage auth <name> [--login]`                                                                   | Refresh or verify credentials for an environment                              |
| `trusty-cage launch <name> -p/--prompt\|--prompt-file\|--test [--background] [--no-inject-messaging]` | Launch Claude Code inside a cage (messaging instructions injected by default) |
| `trusty-cage logs [name] [-f] [-r/--raw] [-n/--lines N]`                                              | Stream inner Claude's reasoning (pretty-printed by default)                   |
| `trusty-cage outbox <name> [-a/--all] [--json] [--poll] [--timeout] [--interval] [--no-diagnose]`     | Read messages from a cage's outbox (auto-diagnoses on poll timeout)           |
| `trusty-cage inbox <name> <type> <payload_json>`                                                      | Send a message to a cage's inbox                                              |
| `trusty-cage diagnose <name> [--json]`                                                                | Diagnostic sweep: inner process state, outbox, git, stream tail, suggestion   |
| `trusty-cage salvage <name> [-y/--yes] [--output-dir]`                                                | Rescue work from a cage that didn't reach task_complete (diagnose + export)   |

## Export, Diff, and Sync

### Safe Export (default)

By default, `tc export` copies cage files onto the host **without** deleting host-only files. This prevents accidental removal of `.gitignore`, `venv/`, `.env`, and other files that exist only on the host.

```bash
tc export myenv -y                     # safe: adds/updates files, never deletes
tc export myenv -y --delete            # opt-in: mirror cage exactly (deletes host-only files)
tc export myenv -y --protect "*.md"    # exclude additional patterns from sync
tc export myenv -y --dir shared-lib    # export a specific additional dir
tc export myenv -y --all               # export main project + all additional dirs
```

Files matching patterns in your `.gitignore` and `.cageprotect` are always excluded from both overwrite and deletion, even with `--delete`.

### `.cageprotect` file

Create a `.cageprotect` file in your project root to protect host-only files during export. Same format as `.gitignore` — one pattern per line, `#` for comments:

```
# Host-only config (never touched by tc export)
CLAUDE.md
.env.production
secrets/

# Protect host .gitignore if you don't want the cage version overwriting it
.gitignore
```

**How it works:** `tc export` rsyncs files from the cage to the host. Patterns in `.gitignore` (read from the host target) are excluded from both overwrite and deletion. `.cageprotect` adds additional patterns that you want protected beyond what `.gitignore` covers.

**Hardcoded exclusions:** `.git/`, `.cageprotect`, `venv/`, and `.venv/` are always excluded, regardless of config.

**About `.gitignore`:** The cage's `.gitignore` file itself **does** transfer to the host during export — if an agent adds a new pattern to `.gitignore` inside the cage, that change reaches the host. If you want to protect the host's `.gitignore` from being overwritten, list it in `.cageprotect`.

### Preview with `tc diff`

See what `tc export` would change before running it:

```bash
tc diff myenv                          # summary table (added/modified/deleted)
tc diff myenv --full                   # full unified diffs
tc diff myenv --output-dir .           # compare against a specific directory
tc diff myenv --dir shared-lib         # diff a specific additional dir
tc diff myenv --all                    # diff main project + all additional dirs
```

### Push fixes with `tc sync`

After exporting and fixing something on the host, push changes back into a running cage:

```bash
tc sync myenv -y                       # sync all host files into cage
tc sync myenv -y --files "main.py"     # sync specific files only
tc sync myenv -y --dir shared-lib      # sync a specific additional dir
tc sync myenv -y --all                 # sync main project + all additional dirs
```

### Typical workflow

```bash
tc diff myenv                          # preview changes
tc export myenv -y                     # export (safe default)
# ... fix something on the host ...
tc sync myenv -y --files "fixed.py"    # push fix back into cage
tc launch myenv --prompt "Continue"    # resume work in cage
```

## Configuration

Configuration is resolved in order: CLI flags > environment variables > `~/.trusty-cage/.env` > defaults.

| Variable                        | Default   | Description                                                                               |
| ------------------------------- | --------- | ----------------------------------------------------------------------------------------- |
| `TRUSTY_CAGE_DOTFILES_REPO`     | _(empty)_ | HTTPS URL of dotfiles repo to clone into containers                                       |
| `TRUSTY_CAGE_PYTHON_VERSION`    | `3.12`    | Python version installed via pyenv                                                        |
| `TRUSTY_CAGE_DEFAULT_SHELL`     | `zsh`     | Default shell inside the container                                                        |
| `TRUSTY_CAGE_DEFAULT_AUTH_MODE` | `api_key` | Auth mode: `api_key` or `subscription`                                                    |
| `TRUSTY_CAGE_TMUX_PREFIX`       | `C-a`     | tmux prefix key inside containers (default `Ctrl-a` to avoid conflict with host `Ctrl-b`) |
| `ANTHROPIC_API_KEY`             | _(none)_  | API key for Claude Code (required for `api_key` auth mode)                                |

Run `trusty-cage init` to create `~/.trusty-cage/.env` with a commented template you can customize.

## Dotfiles

If you set `TRUSTY_CAGE_DOTFILES_REPO`, your dotfiles are automatically applied to every new container at `create` time. The repo is cloned on the host, `.git/` is stripped, and the files are copied into the container's home directory. If an install script is found (`install.sh`, `setup.sh`, `bootstrap.sh`, etc.), it runs automatically. GNU Stow layouts are detected and handled (files are copied from `common/` if present).

This means your shell config, tmux settings, Neovim config, aliases, and other personalizations carry over — the container feels like your own machine.

**Without dotfiles**, the container ships with sensible defaults: oh-my-zsh (robbyrussell theme), LazyVim starter config, pyenv on PATH, and `vim`/`vi` aliased to `nvim`. Everything works out of the box, just without your personal customizations.

## Authentication

Chosen at `create` time via `--auth-mode`:

- **api_key** — Reads `ANTHROPIC_API_KEY` from your host shell at attach/launch time. Injected via `docker exec -e`, never written to disk. Best for API billing users.
- **subscription** — Copies `~/.claude/`, `~/.claude.json`, and OAuth tokens from macOS Keychain into the container at create time. Persists in the volume. Best for Claude Pro/Max subscribers — no API key needed.

```bash
# Create with API key auth (default)
tc create https://github.com/user/repo
# Requires ANTHROPIC_API_KEY to be set in your shell

# Create with subscription auth (Claude Pro/Max)
tc create https://github.com/user/repo --auth-mode subscription
# Automatically extracts OAuth tokens from macOS Keychain

# Refresh credentials at any time
tc auth myenv

# If subscription tokens have expired, re-login interactively
tc auth myenv --login
```

On macOS, subscription mode extracts OAuth tokens from the system Keychain (`Claude Code-credentials`) and writes them as `~/.claude/.credentials.json` inside the container. This bridges macOS Keychain storage with Linux's file-based fallback — no manual `/login` step needed.

## Custom Dockerfile

By default, trusty-cage uses a built-in Dockerfile (Ubuntu 24.04 with Python, Node.js, Neovim, tmux, Claude Code). You can replace it entirely with your own:

```bash
# Via CLI flag (highest priority)
tc create https://github.com/user/repo --dockerfile /path/to/Dockerfile

# Via convention path (used if no flag is passed)
# Place your Dockerfile at ~/.trusty-cage/Dockerfile

# Rebuild the image with a custom Dockerfile
tc rebuild-image --dockerfile /path/to/Dockerfile
```

Custom Dockerfiles fully replace the default — you are responsible for including the `trustycage` user (UID 1000), required tools, and any security constraints your workflow requires.

### Extending the Base Image

If you only need to add packages or tools on top of the default image, use Docker's `FROM` inheritance instead of replacing the entire Dockerfile:

```dockerfile
FROM trusty-cage:latest

# Add system packages
RUN apt-get update && apt-get install -y postgresql-client redis-tools

# Add Python packages
RUN pip install pandas requests

# Add project-specific tooling
COPY my-linter.conf /home/trustycage/.config/
```

Build the base image first (happens automatically on `tc create`), then pass your extending Dockerfile:

```bash
tc create https://github.com/user/repo --dockerfile ./Dockerfile.custom
```

This preserves the base setup (non-root user, tmux, Neovim, Claude Code, network policy) while letting you layer on project-specific dependencies.

## Orchestration

trusty-cage supports two modes of use:

1. **Interactive** — `tc attach` drops you into a tmux session with Claude Code, Neovim, and a shell. You prompt Claude directly and watch it work.
2. **Headless** — `tc launch` runs Claude non-interactively with a prompt. An outer Claude (or script) orchestrates the inner Claude, monitors progress, and exports results.

### Headless Workflow

```bash
# Create a cage (no interactive attach)
tc create https://github.com/user/repo --name myproject --auth-mode subscription --no-attach

# Verify Claude can start (pre-flight check)
tc launch myproject --test

# Send a task
tc launch myproject --prompt "Implement feature X" --background

# Watch the inner Claude's reasoning in real-time (from the host)
tc logs myproject -f

# Or get raw stream-json for programmatic consumption
tc logs myproject -f --raw

# For long prompts, use a file
tc launch myproject --prompt-file /path/to/prompt.txt --background

# Poll for completion (blocks until task_complete arrives)
tc outbox myproject --poll --timeout 1800

# Or read outbox messages manually
tc outbox myproject --all --json

# Send a response to the inner Claude's inbox
tc inbox myproject info_response '{"request_id":"req-001","content":"file contents"}'

# When done, export and overlay onto your working directory
tc export myproject -y --output-dir .

# Clean up
tc destroy myproject -y
```

### Monitoring with `tc logs`

`tc logs` streams the inner Claude's reasoning from outside the cage — no attach needed. Output is pretty-printed by default:

```
INIT session=a48c7ada... model=claude-opus-4-6[1m]
THINKING Simple task - create a weather.py script with temperature conversion.
TOOL Write: /home/trustycage/project/weather.py
RESULT File created successfully at: /home/trustycage/project/weather.py
TOOL Bash: python weather.py
RESULT 0°C = 32.00°F ...
CLAUDE Script created and working.
DONE Script created and working.
     cost=$0.1563 duration=10.5s
```

Use `--raw` for the full stream-json output. Use `-f` / `--follow` to tail in real-time.

### `cage-send` (Inside the Container)

The container image includes `cage-send` at `/usr/local/bin/cage-send` — a helper script that handles message envelope construction (id, timestamp, version) so the inner Claude doesn't have to build JSON by hand:

```bash
# Report progress
cage-send progress_update '{"status":"implementing auth module","detail":"3 of 5 files done"}'

# Request a file from the host
cage-send info_request '{"request_id":"req-001","description":"Need package.json","paths":["package.json"]}'

# Report an error
cage-send error '{"error_type":"missing_dep","message":"need ffmpeg","recoverable":true}'

# Signal completion (REQUIRED as final action)
cage-send task_complete '{"summary":"Implemented feature X","exit_code":0}'
```

Valid types: `task_complete`, `progress_update`, `error`, `info_request`, `going_idle`. The script validates the type and JSON payload before writing.

### Messaging System

The container includes a file-based message bus for structured communication between the inner and outer Claude. Messages are timestamped JSON files in well-known directories:

```
/home/trustycage/.cage/
  outbox/                  # Inner Claude writes here (via cage-send), outer reads (via tc outbox)
  inbox/                   # Outer Claude writes here (via tc inbox), inner reads
  cursor/                  # Tracks read position (Kafka-like offset)
  claude-stream.log        # Stream-json output from tc launch (readable via tc logs)
```

**Message types:**

| Type              | Direction      | Purpose                                          |
| ----------------- | -------------- | ------------------------------------------------ |
| `task_complete`   | inner -> outer | Signal task is done (includes summary)           |
| `progress_update` | inner -> outer | Report what's being worked on                    |
| `info_request`    | inner -> outer | Ask for files/data from the host                 |
| `error`           | inner -> outer | Report a blocker                                 |
| `info_response`   | outer -> inner | Respond to an info_request                       |
| `ack`             | outer -> inner | Acknowledge receipt of a message                 |
| `task_revision`   | outer -> inner | Send revised instructions after review           |
| `going_idle`      | inner -> outer | Inner Claude's polling timed out, session ending |

**Message format:**

```json
{
  "id": "msg-20260326T143000-a1b2",
  "type": "task_complete",
  "timestamp": "2026-03-26T14:30:00.000Z",
  "payload": { "summary": "Implemented feature X", "exit_code": 0 },
  "version": 1
}
```

The messaging system is initialized automatically during `tc create`. The new `task_revision` and `going_idle` message types support an iteration loop — inner Claude polls for revised instructions after completing a task, allowing multiple rounds of feedback without destroying the cage.

### Using the Cage Orchestrator Skill

The easiest way to use trusty-cage in headless mode is through the **cage-orchestrator** skill. Instead of running CLI commands manually, you tell your outer Claude to delegate work to an isolated cage:

> "Spin up a cage and implement feature X in this repo"

The cage-orchestrator skill handles the full lifecycle automatically:

1. **Creates** a cage from the current repo's git remote
2. **Launches** inner Claude with your task description
3. **Monitors** progress via `tc logs` and `tc outbox --poll`
4. **Relays** progress updates and handles information requests from inner Claude
5. **Exports** completed work back to your working directory
6. **Reviews** changes with you (`git diff`)
7. **Iterates** — if you have feedback, it sends revised instructions back to inner Claude (who stays alive and keeps context), and the cycle repeats
8. **Cleans up** when you're done

The inner Claude works with full autonomy inside the container — installing packages, editing files, running tests — while the outer Claude keeps you informed and handles anything that requires host access (git push, file retrieval, approvals).

#### Install the skills

Install the companion plugin by running these commands inside a [Claude Code](https://claude.ai/code) session:

```
/plugin marketplace add areese801/trusty-cage-plugin
/plugin install trusty-cage@trusty-cage-plugin
```

The skills activate automatically when you ask Claude to run something in a cage. See [trusty-cage-plugin](https://github.com/areese801/trusty-cage-plugin) for details.

## Security Model

The container is the blast radius. If an agent does something destructive, your host is unaffected.

**What agents can do inside:**

- Clone/fetch public repos over HTTPS
- Browse the web, read docs, hit public APIs
- Install packages (pip, apt, npm)
- Full read/write access to the project directory

**What agents cannot do:**

- Push to any git remote (no credentials present)
- Use SSH (port 22 blocked)
- Pull Docker images from Docker Hub (blocked)
- Access any host files (no bind mounts)

Protection is enforced by **credential absence**, not network blocking. The container has no SSH keys, no `.netrc`, no `GH_TOKEN`, no git credential helper.

## Requirements

- Docker (Docker Desktop, OrbStack, or Docker Engine)
- Python 3.11+
- Git
- rsync (pre-installed on macOS and most Linux distros; used by `export`)

## Development

```bash
git clone https://github.com/areese801/trusty-cage.git
cd trusty-cage
python -m venv venv
source venv/bin/activate
pip install -e ".[dev]"

# Available make targets
make help
```

## License

MIT
test
