diff --git a/README.md b/README.md index 9690d60..6bd9ddb 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,14 @@ --- > [!CAUTION] -> **⚠️ Unreleased Software ⚠️** +> **Unreleased Software** > -> 🚧 This project is under active development and **not yet released**. APIs, configuration formats, and behavior may change without notice. Please **do not use without contacting the author** about the current state of the project. 🚧 +> This project is under active development and **not yet released**. APIs, configuration formats, and behavior may change without notice. Please **do not use without contacting the author** about the current state of the project. > [!WARNING] -> **⚠️ Development Tool Only ⚠️** +> **Development Tool Only** > -> 🚧 Sandcage is designed for **local development use**. Do **not** use it in CI pipelines or production environments — container isolation is not yet hardened for those contexts. 🚧 - -### Planned Features - -- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI -- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials -- **ACP integration** via [`dirigate`](https://github.com/dirigence/dirigate) — Agent Communication Protocol support for structured agent orchestration +> Sandcage is designed for **local development use**. Do **not** use it in CI pipelines or production environments — container isolation is not yet hardened for those contexts. --- @@ -36,107 +30,99 @@ Sandcage gives each agent its own container with the tools it needs. Your projec Multiple agents can run side by side. A persistent home directory means config and credentials survive between sessions, so you are not re-authenticating every time. + + +## Quick Start + +**Prerequisites:** Docker (running) and a Rust toolchain (cargo). + +```bash +# Install +cargo install --git https://github.com/dirigence/sandcage + +# Build the container image +sandcage build + +# Run Claude Code in the current project +sandcage claude +``` + +That's it. Sandcage resolves your project to its git root, mounts it into the container, and drops you into the agent. + +## Usage + +``` +sandcage claude # Claude Code agent +sandcage codex # Codex agent +sandcage gemini # Gemini CLI agent +sandcage shell # interactive shell, same environment +sandcage claude -p ~/project # run in a specific project +sandcage claude -- --resume # forward args to the agent +sandcage claude --shell # shell for debugging +sandcage build # build/rebuild container image +sandcage init # generate .sandcage.yml for your project +sandcage setup ssh # configure SSH key access for containers +``` + ## How It Works

Sandcage topology — host, Docker, container, volume mounts

-1. You run `sandcage claude` (or `codex`, `gemini`, or `shell`) from your project directory -2. Sandcage resolves your workspace to the git root and builds Docker compose arguments +1. You run `sandcage claude` from your project directory +2. Sandcage resolves the workspace, loads layered config, and generates a compose definition 3. Your project, persistent home, and (optionally) SSH keys are mounted into the container 4. The agent runs as the container entrypoint, working in the mounted workspace 5. All file changes are immediately visible on your host -## Quick Start - -### Prerequisites - -- **Docker** (daemon must be running) -- **Rust toolchain** (cargo) — or download a prebuilt binary from [Releases](https://github.com/dirigence/sandcage/releases) - -### Install - -```bash -cargo install --git https://github.com/dirigence/sandcage -``` - -### Build images and run - -```bash -sandcage build # build container image -sandcage claude # start Claude Code in the current project -``` - -That is it. Sandcage resolves your project to its git root, mounts it into the container, and drops you into the agent. - -### More commands - -``` -sandcage claude -p ~/project # run in a specific project -sandcage claude -- --resume # forward args to the agent -sandcage codex -p ~/project # run Codex instead -sandcage gemini -p ~/project # run Gemini CLI instead -sandcage shell # interactive shell, same environment -sandcage claude --shell # shell in the Claude image (debugging) -sandcage init # detect ecosystem, generate .sandcage.yml -sandcage setup ssh # select and copy SSH keys for containers -sandcage setup ssh --global # store SSH config globally -sandcage setup ssh --refresh # re-sync keys after changes -``` - ## Configuration

Configuration layering — defaults, global, project, local, CLI flags

-Configuration is layered. Each level overrides the one below it, so you only set what you need: +Configuration is layered — each level overrides the one below: -**Compiled defaults** — sensible out of the box -**Global config** (`~/.sandcage/config.toml`) — user-wide preferences -**Project config** (`.sandcage.yml`) — per-project setup, checked into version control -**Local config** (`.sandcage.local.yml`) — personal overrides, gitignored (SSH keys, secrets, local mounts) -**CLI flags** — per-invocation overrides - -### .sandcage.yml example - -```yaml -packages: - - ripgrep - - fd-find -toolchains: - rust: stable - node: "20" -env: - DATABASE_URL: "postgres://localhost:5432/dev" -agent_args: - claude: - - --dangerously-skip-permissions -shell: zsh - -# Enable/disable built-in services -services: - gemini: - enabled: false - -# Control which services `sandcage build` prepares by default -# default_services: -# - claude -# - shell -``` - -SSH key access is configured separately via `sandcage setup ssh`, which selects only the keys needed for git and copies them into a dedicated Docker volume. +| Layer | File | Format | +|-------|------|--------| +| Global | `~/.sandcage/config.toml` | TOML | +| Project | `.sandcage.yml` | YAML | +| Local | `.sandcage.local.yml` | YAML | Run `sandcage init` to generate a starter config — it detects your project ecosystem (Rust, Node, Python, Go) and suggests appropriate toolchains and packages. -## Docker Image +```yaml +# .sandcage.yml — minimal example +toolchains: + node: "20" +packages: + - ripgrep +services: + gemini: + enabled: false +``` -Sandcage uses a single image (`sandcage`) based on Debian bookworm-slim, packed with dev tools: git, openssh-client, ripgrep, fd, jq, curl, zsh, bash, sudo, just, and uv. +See **[Configuration Reference](docs/configuration.md)** for all available options. -AI agents (Claude Code, Codex, Gemini CLI) are installed on first run into the persistent home directory and auto-update themselves — no agent binaries baked into the image. +## Documentation -Build with `sandcage build`. Use `--force` to rebuild unconditionally. You can also specify which services to build: `sandcage build claude codex`. +| Document | Description | +|----------|-------------| +| [Configuration Reference](docs/configuration.md) | All config fields, merge behavior, and examples | +| [Command Reference](docs/commands.md) | Every subcommand, flag, and usage pattern | +| [Docker Image](docs/docker-image.md) | Base image contents, building, custom Dockerfiles | +| [SSH Key Access](docs/ssh.md) | Setting up SSH for git inside containers | + +## Planned Features + +- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI +- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials +- **ACP integration** via [`dirigate`](https://github.com/dirigence/dirigate) — Agent Communication Protocol support for structured agent orchestration ## Cross-Platform diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..c2aef66 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,223 @@ +# Command Reference + +Sandcage provides subcommands for running AI coding agents in isolated Docker containers. + +``` +sandcage [OPTIONS] +``` + +--- + +## claude + +Run the Claude Code agent in a sandboxed container. + +``` +sandcage claude [OPTIONS] [-- AGENT_ARGS...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | +| `--shell` | Drop into a shell instead of launching the agent | + +**Trailing arguments:** Any arguments after `--` are forwarded directly to the Claude Code binary inside the container. + +**Examples:** + +```sh +# Run Claude Code on the current directory +sandcage claude + +# Run Claude Code on a specific project +sandcage claude --path /home/user/myproject + +# Drop into a shell in the Claude container environment +sandcage claude --shell + +# Forward arguments to the agent +sandcage claude -- --resume +``` + +--- + +## codex + +Run the OpenAI Codex agent in a sandboxed container. + +``` +sandcage codex [OPTIONS] [-- AGENT_ARGS...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | +| `--shell` | Drop into a shell instead of launching the agent | + +**Trailing arguments:** Any arguments after `--` are forwarded directly to the Codex binary inside the container. + +**Examples:** + +```sh +# Run Codex on the current directory +sandcage codex + +# Forward arguments to Codex +sandcage codex -- --model o4-mini +``` + +--- + +## gemini + +Run the Gemini CLI agent in a sandboxed container. + +``` +sandcage gemini [OPTIONS] [-- AGENT_ARGS...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | +| `--shell` | Drop into a shell instead of launching the agent | + +**Trailing arguments:** Any arguments after `--` are forwarded directly to the Gemini CLI binary inside the container. + +**Examples:** + +```sh +# Run Gemini CLI on the current directory +sandcage gemini + +# Drop into a shell in the Gemini container +sandcage gemini --shell +``` + +--- + +## shell + +Open an interactive shell (zsh) with the same sandboxed environment used by agents. + +``` +sandcage shell [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | + +**Examples:** + +```sh +# Interactive shell in the sandbox for current project +sandcage shell + +# Interactive shell for a specific project +sandcage shell --path /home/user/myproject +``` + +--- + +## build + +Build the container images used by sandcage services. + +``` +sandcage build [OPTIONS] [SERVICES...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-f, --force` | Force rebuild even if images are up to date | + +**Positional arguments:** Optionally specify which services to build (e.g. `claude`, `codex`, `gemini`). When omitted, all enabled services are built. + +**Examples:** + +```sh +# Build all enabled service images +sandcage build + +# Force rebuild of all images +sandcage build --force + +# Build only the claude image +sandcage build claude + +# Build claude and gemini images +sandcage build claude gemini +``` + +--- + +## init + +Initialize a `.sandcage.yml` configuration file for a project. + +``` +sandcage init +``` + +Scaffolds a project configuration in the current workspace directory. No options. + +**Examples:** + +```sh +# Initialize sandcage config in current directory +sandcage init +``` + +--- + +## setup ssh + +Configure SSH key access for containers. Copies selected SSH keys into a Docker volume accessible by sandcage containers. + +``` +sandcage setup ssh [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--global` | Write to global config (`~/.sandcage/config.toml`) instead of project config | +| `--yes` | Skip confirmation prompt | +| `--refresh` | Re-populate the SSH volume using the previously saved selection | +| `--bind` | Use legacy full bind mount instead of volume copy | + +**Examples:** + +```sh +# Interactive SSH key setup for the current project +sandcage setup ssh + +# Non-interactive setup writing to global config +sandcage setup ssh --global --yes + +# Refresh the SSH volume after adding new keys +sandcage setup ssh --refresh +``` + +--- + +## Common Patterns + +**Project path resolution:** All agent commands and `shell` accept `--path` (`-p`) to specify the project directory. When omitted, sandcage resolves the workspace from the current directory. + +**Shell override:** The `--shell` flag on agent commands (`claude`, `codex`, `gemini`) drops you into an interactive shell inside the agent's container without launching the agent itself. Useful for debugging the container environment. + +**Forwarding arguments to agents:** Agent commands accept trailing arguments (after `--`) that are passed through to the underlying agent binary. This allows setting agent-specific flags without sandcage needing to know about them. + +**Available services:** The built-in services are `claude`, `codex`, `gemini`, and `shell`. Each agent service auto-installs its binary on first run if not already present in the container. diff --git a/docs/docker-image.md b/docs/docker-image.md new file mode 100644 index 0000000..7828328 --- /dev/null +++ b/docs/docker-image.md @@ -0,0 +1,110 @@ +# Docker Image + +## Base image contents + +The sandcage image is built on `debian:bookworm-slim` and includes: + +- **Shell:** zsh (default), bash +- **VCS:** git, openssh-client +- **Search:** ripgrep, fd-find +- **Utilities:** jq, curl, sudo, ca-certificates, npm +- **Task runner:** just (installed from upstream binary) +- **Python tooling:** uv (installed per-user at `/home/agent/.local/bin`) + +The image creates an `agent` user (UID 1000) with a home at `/home/agent`, zsh as the default shell, and passwordless sudo. + +Pre-created directories: `/workspace`, `/home/agent/.claude`, `/home/agent/.codex`, `/home/agent/.gemini`. + +## How agents are installed + +AI coding agents (Claude Code, Codex, Gemini CLI) are **not** baked into the image. They are installed on first run via their respective entrypoint scripts. The agent home directories (`~/.claude`, `~/.codex`, `~/.gemini`) are persisted across runs through volume mounts to `~/.sandcage/` on the host, so installation only happens once. + +Each service has its own entrypoint script installed at `/usr/local/bin/sandcage--entrypoint`. + +## Building images + +Build the sandcage image with: + +``` +sandcage build +``` + +### Force rebuild + +To bypass the cache and rebuild unconditionally: + +``` +sandcage build --force +``` + +When `--force` is used, Docker's `--no-cache` flag is also passed to ensure layers are rebuilt from scratch. + +### Service filter + +Build only images required for specific services: + +``` +sandcage build claude shell +``` + +Unknown service names produce an error listing available services. + +## Cache awareness + +Sandcage uses hash-based rebuild detection to avoid unnecessary image builds. + +On each build, the SHA-256 hash of the Dockerfile content (bundled or custom) is computed and compared against the stored hash in `~/.sandcage/.build-hashes`. If they match, the build is skipped with an "up to date" message. + +After a successful build, the new hash is persisted. The hash file uses a simple `image:hash` line format. + +This means: + +- Editing the Dockerfile (or switching to/from a custom one) triggers a rebuild automatically. +- The `--force` flag bypasses hash comparison entirely. + +## Custom Dockerfiles + +You can override the bundled Dockerfile by specifying a path in configuration. + +### Global config (`~/.sandcage/config.toml`) + +```toml +[dockerfiles] +sandcage = "/path/to/my/Dockerfile" +``` + +### Project config (`.sandcage.yml`) + +```yaml +dockerfiles: + sandcage: ./docker/Dockerfile.custom +``` + +The path can point to either: + +- A **file** -- used as the Dockerfile with a temporary build context. +- A **directory** -- used as the full build context (must contain a `Dockerfile`). + +### trusted_projects requirement + +Project-level Dockerfile overrides are only applied if the project directory is listed in the global `trusted_projects` setting. This prevents untrusted repositories from injecting arbitrary build instructions. + +```toml +# ~/.sandcage/config.toml +trusted_projects = [ + "/home/user/projects/my-trusted-repo", + "/home/user/work", +] +``` + +A project is considered trusted if its canonical path starts with any entry in `trusted_projects` (prefix matching). If a project specifies `dockerfiles` but is not trusted, the override is silently ignored with a warning. + +Global config overrides (`~/.sandcage/config.toml`) are always trusted. + +## Cross-platform notes + +### UID/GID handling + +On **Linux**, sandcage queries the host user's UID and GID via `id -u` / `id -g` and passes them into the container as `SANDCAGE_UID` and `SANDCAGE_GID`. This ensures files created inside the container have correct ownership on bind-mounted host directories. + +On **Windows**, UID/GID passthrough is not meaningful. Sandcage hardcodes both to `1000`, matching the `agent` user built into the image. File ownership on Windows bind mounts is handled by Docker Desktop's filesystem sharing layer. diff --git a/docs/ssh.md b/docs/ssh.md new file mode 100644 index 0000000..880ea5a --- /dev/null +++ b/docs/ssh.md @@ -0,0 +1,224 @@ +# SSH Key Access + +## Overview + +Containers launched by sandcage often need SSH access to clone private +repositories or push code. Since the agent user inside the container does not +have access to your host SSH keys by default, sandcage provides a setup command +that copies or mounts the relevant keys into the container environment. + +## Modes + +Sandcage supports two SSH modes, configured via the `ssh_mode` field: + +### Volume mode (recommended) + +``` +ssh_mode: volume +``` + +A Docker named volume (`sandcage-ssh`) is created and populated with only the +keys required for your git remotes. The volume is mounted read-only at +`/home/agent/.ssh` inside the container. A synthesized SSH config is written +into the volume so the agent can connect to the correct hosts with the correct +keys. + +Advantages: + +- Only selected keys are copied (not your entire `~/.ssh` directory). +- A minimal SSH config is synthesized from your host config. +- The volume persists across container restarts without re-reading host files. +- Works identically on Linux, macOS, and Windows (Docker Desktop). + +### Bind mode + +``` +ssh_mode: bind +``` + +Your host `~/.ssh` directory is bind-mounted read-only into the container at +`/home/agent/.ssh:ro`. This exposes all files in that directory to the +container. + +Advantages: + +- Zero setup after initial configuration. +- Changes to host keys are immediately visible. + +Disadvantages: + +- Exposes all keys and config, not just the ones needed. +- On some platforms, file permission translation can cause issues. + +### None + +``` +ssh_mode: none +``` + +No SSH mount is added. Use this if you do not need SSH inside containers or +handle key injection through other means. + +## Setup workflow + +Run the interactive setup: + +``` +sandcage setup ssh +``` + +The command: + +1. Scans `~/.ssh/config` (including `Include` directives) for `Host` blocks + with `User git`. +2. Runs `git remote -v` in the current directory to discover SSH remote hosts. +3. Merges the two sources and displays discovered host/key pairs. +4. Prompts you to select which keys to copy. +5. Asks whether to include `known_hosts`. +6. Populates the `sandcage-ssh` Docker volume with the selected keys and a + synthesized SSH config. +7. Writes the configuration to the appropriate config file. + +### Flags + +| Flag | Effect | +|------|--------| +| `--global` | Write to `~/.sandcage/config.toml` instead of project-local config | +| `--yes` | Accept all defaults without prompting | +| `--bind` | Use bind-mount mode instead of volume mode | +| `--refresh` | Re-populate the volume from existing config (no interactive selection) | + +### Examples + +``` +# Interactive setup for current project +sandcage setup ssh + +# Non-interactive, global config +sandcage setup ssh --global --yes + +# Legacy bind-mount mode +sandcage setup ssh --bind + +# Refresh volume after adding a new key to ~/.ssh +sandcage setup ssh --refresh +``` + +## Configuration + +### Project config (`.sandcage.local.yml` or `.sandcage.yml`) + +```yaml +ssh_mode: volume +ssh_keys: + - host: github.com + identity_file: ~/.ssh/id_ed25519 + - host: gitea + identity_file: ~/.ssh/work_gitea +``` + +### Global config (`~/.sandcage/config.toml`) + +```toml +ssh_mode = "volume" + +[[ssh_keys]] +host = "github.com" +identity_file = "~/.ssh/id_ed25519" + +[[ssh_keys]] +host = "gitea" +identity_file = "~/.ssh/work_gitea" +``` + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `ssh_mode` | string | One of `volume`, `bind`, or `none` | +| `ssh_keys` | array | List of host/key pairs to include in the volume | +| `ssh_keys[].host` | string | SSH host alias (matches `Host` line in SSH config) | +| `ssh_keys[].identity_file` | string | Path to the private key (supports `~/` expansion) | + +Configuration layers are merged in order: global, project, local. Later layers +override earlier ones. + +## Refreshing keys + +After adding or rotating SSH keys on the host, update the container volume: + +``` +sandcage setup ssh --refresh +``` + +This re-reads the existing `ssh_keys` configuration and the current +`~/.ssh/config`, then repopulates the `sandcage-ssh` Docker volume with fresh +copies. + +For bind mode, no refresh is needed since the host directory is mounted +directly. + +## Security considerations + +- **Volume mode** copies only the selected private keys into a Docker volume. + The volume is mounted read-only into containers. Keys are owned by the + `agent` user with `600` permissions. +- **Bind mode** mounts the entire `~/.ssh` directory read-only. The container + can read all keys and config present in that directory. +- Neither mode exposes keys to other containers or Docker networks. Keys exist + only on the filesystem inside the running container. +- The `sandcage-ssh` volume persists on disk until explicitly removed + (`docker volume rm sandcage-ssh`). Run `--refresh` or delete the volume when + decommissioning keys. + +## Troubleshooting + +### Volume not found + +``` +sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it +``` + +The `sandcage-ssh` volume does not exist or was removed. Re-run: + +``` +sandcage setup ssh --refresh +``` + +Or, if no config exists yet: + +``` +sandcage setup ssh +``` + +### Permission denied inside container + +If the agent cannot read keys, the volume population step may have failed or +the image does not have an `agent` user at the expected UID. Verify: + +``` +docker run --rm -v sandcage-ssh:/home/agent/.ssh:ro sandcage:latest ls -la /home/agent/.ssh +``` + +Private keys should be `600` and owned by `agent:agent`. + +### No git SSH hosts found + +``` +sandcage: no git SSH hosts found in remotes or ~/.ssh/config +``` + +This means: + +- The current directory has no SSH git remotes (all remotes use HTTPS). +- `~/.ssh/config` has no `Host` blocks with `User git`. + +Add an SSH remote or configure your SSH config, then re-run setup. + +### Migration from legacy bind mount + +If your config contains a raw `~/.ssh:/home/agent/.ssh:ro` entry in `mounts` +without an `ssh_mode` field, sandcage detects this as a legacy configuration +and offers to migrate to volume mode. Accepting the migration removes the old +mount entry and writes `ssh_mode: volume` with discovered keys. Declining sets +`ssh_mode: bind` explicitly to suppress future prompts.