io.github.IronSecCo/ironclaw
Sandboxed shell exec for MCP clients: run untrusted agent commands in a gVisor container.
Open source Open in the app JSON README (API)
About
Sandboxed shell exec for MCP clients: run untrusted agent commands in a gVisor container.
Details
- Kind
- MCP servers
- Topic
- Cloud & DevOps
- Publisher
- ironsecco
- Origin
- official
- Category
- ferramentas
- Transport
- local
- Version
- 0.1.521
- Stars
- 19
- Forks
- 15
- Open pull requests
- 1
- Last push
- 2026-09-08T12:19:11Z
- Repository state
- ativo
- Language
- Go
- License
- AGPL-3.0
- Added
- 2026-08-29 03:01:58
- Updated
- 2026-09-03 08:00:27
- Origin id
io.github.IronSecCo/ironclaw
README
<div align="center">
<img src="docs/assets/logo.svg" alt="IronClaw" width="380">
### Self-hosted AI agents you do not have to trust.
Each one runs sealed in a sandbox that provably cannot phone home, read your host, or rewrite its own rules.
<!-- Security & supply-chain trust cluster — lead with what makes this project different -->
[](https://scorecard.dev/viewer/?uri=github.com/IronSecCo/ironclaw)
[](https://www.bestpractices.dev/projects/13348)
[](https://github.com/IronSecCo/ironclaw/actions/workflows/codeql.yml)
[](#verifying-a-release)
[](#verifying-a-release)
[](#verifying-a-release)
[](https://github.com/marketplace/actions/ironclaw-sandbox-scan)
[](https://ironsecco.github.io/ironclaw/scan-action/)
[](https://ironsecco.github.io/ironclaw/scan/#sandbox-isolation-score-badge)
[](https://ironsecco.github.io/ironclaw/)
[](#project-status)
[](https://github.com/IronSecCo/ironclaw/releases/latest)
[](https://pkg.go.dev/github.com/IronSecCo/ironclaw)
[](LICENSING.md)
[](https://github.com/IronSecCo/ironclaw/discussions)
[](https://github.com/IronSecCo/ironclaw/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
[](https://github.com/IronSecCo/ironclaw/stargazers)
</div>
**IronClaw runs autonomous AI agents on infrastructure you control**, reached through the chat apps
you already use. Each agent can read, write, schedule, and reply like any assistant, but it lives
inside a sealed sandbox with `network=none`: it reaches the model only through a host proxy, and it
**cannot change its own configuration.** It is for anyone who wants what agents can do without
handing an autonomous program the keys to their machine.
> **Now on the GitHub Marketplace.** The `ironctl scan` containment grader ships as a
> [GitHub Action](https://github.com/marketplace/actions/ironclaw-sandbox-scan): drop one
> line into a workflow and every pull request gets a 0 to 100 sandbox isolation scorecard as
> a sticky comment. Local, read-only, credential-free.
>
> ```yaml
> # .github/workflows/scan.yml
> - uses: IronSecCo/ironclaw@v1
> with:
> target: my-container # a container, compose service, or k8s manifest
> ```
>
> Report-only by default; set `min-score: 90` to gate merges. See [scan in CI](https://ironsecco.github.io/ironclaw/scan-action/).
<div align="center">
<!--
Motion sells: a REAL recording of examples/live-containment (docs/assets/live-containment.cast,
regenerable via the "Re-record the clip" note in that example's README). The static final-frame
SVG is the reduced-motion / no-animation fallback. No autoplay-with-sound (GIF has no audio).
-->
<picture>
<source media="(prefers-reduced-motion: reduce)" srcset="docs/assets/containment.svg">
<img src="docs/assets/live-containment.gif" width="820" alt="live-containment demo: one command engages a real per-session sandbox, then a fully-jailbroken agent tries three escapes from inside the box and each is BLOCKED. Exfiltrating to the attacker is denied because network=none leaves only the loopback interface and DNS fails; reading the operator's host filesystem is denied because the host root is outside the sandbox mount namespace; seizing the host via the Docker Engine socket is denied because the socket is never mounted in and there is no docker client. It ends with a containment summary that 3 of 3 escape attempts were denied and the box held.">
</picture>
<sub><b>Watch it catch a real escape.</b> A fully-jailbroken agent inside a real sandbox tries to phone home, read the host filesystem, and seize the host through the Docker socket. Each attempt is <b>denied</b> at the isolation boundary, then a containment summary prints. One command, zero credentials, reduced-motion friendly. <a href="examples/live-containment/"><code>examples/live-containment/run.sh</code></a></sub>
</div>
**⭐ Like the idea of agents you do not have to trust? [Star the repo](https://github.com/IronSecCo/ironclaw)** so
it is one click to follow along and easier for the next person to find. Then run the exact demo above
in 30 seconds, no signup and no API key.
### Try it in 30 seconds (zero credentials)
Make sure the **Docker daemon is running** (start Docker Desktop, or `sudo systemctl start docker`
on Linux), then paste one block:
```sh
git clone https://github.com/IronSecCo/ironclaw.git && cd ironclaw
examples/live-containment/run.sh # builds the sandbox once, engages a real sandbox, proves it holds
```
That single command runs the whole secured path on your laptop: it starts the offline mock-agent
control-plane (**no API key**), engages a **real per-session sandbox**, lets a jailbroken agent try
to break out, and prints the containment summary you saw above. Want to chat with an agent in a
browser first? Run [`hello-ironclaw`](examples/hello-ironclaw/) or the
[zero-credential quickstart](docs/quickstart.md). Production seals each sandbox with gVisor and
`network=none`.
> [!WARNING]
> **Alpha software, work in progress. Please read before relying on it.**
>
> - **It's an alpha.** Flags, the on-disk format, and the HTTP/contract surfaces can still change without notice or a migration path. Don't point it at anything you can't afford to lose.
> - **Not every feature is tested end-to-end.** The control-plane, gateway, and encrypted-queue core have real coverage (800+ Go tests plus a black-box parity suite); channel adapters, some tools, multi-provider routing, and a live sandbox launch are exercised more lightly. Treat anything outside the [tested core](#project-status) as experimental.
>
> macOS gets a weaker sandbox boundary than Linux+gVisor, and **native Windows can't run the agent sandbox at all** (use WSL2). See [Platform support](#platform-support).
> **The security model, in one line:** each sandboxed agent runs with `network=none`, reaches the
> model only through a host proxy, and **cannot change its own configuration.** Every capability
> change is held at a gateway for a human decision. The full design is in the
> [architecture overview](docs/architecture.md) and the [threat model](docs/threat-model.md).
### See the whole journey, end to end
<div align="center">
<img src="docs/assets/demo.svg" width="800" alt="Zero-credential chat demo terminal session: one command (docker compose -f docker-compose.demo.yml up -d) starts the offline mock-agent control-plane with no API key; a chat message engages the agent, which launches a real per-session sandbox container (ic-sbx-…); the reply flows back through the encrypted per-session queue.">
<sub><b>Zero credentials, one command.</b> The offline <code>mock-agent</code> runs the full chat to per-session sandbox to reply path with no API key. Production seals each sandbox with gVisor and <code>network=none</code>. <a href="docs/quickstart.md">Quickstart</a></sub>
<br><br>
<img src="docs/assets/walkthrough.svg" width="820" height="595" alt="End-to-end IronClaw walkthrough terminal session in three acts. Act 1: one command starts the offline mock-agent and it replies with no API key. Act 2: connect a real provider by exporting a host-side, redacted ANTHROPIC_API_KEY and starting the real control-plane (each session sealed with gVisor and network=none). Act 3: the agent submits a persona change that is HELD at the human-approval gateway, a human approves it, and the submit-approve-apply trail lands on the append-only audit log.">
<sub><b>Zero-cred demo, connect a real provider, first approved task.</b> The one credential step keeps the key host-side; every agent change is held at the gateway for a human, then written to the append-only audit log. Animation freezes on the final frame under <code>prefers-reduced-motion</code>. <a href="docs/quickstart.md">Quickstart</a></sub>
</div>
## Get running in under two minutes
One command installs the two host binaries (`ironctl` + `ironclaw-controlplane`); in dev mode the
control-plane serves its API at **`http://127.0.0.1:8787`**. From a cold machine, you'll have a
capability change waiting at the security gateway in **under two minutes**:
```sh
# 1. Install — detects your OS/arch and verifies the SHA-256 checksum before installing
curl -fsSL https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.sh | sh
# 2. Start the control-plane in dev mode — API base URL: http://127.0.0.1:8787
export IRONCLAW_API_TOKEN=$(openssl rand -hex 32)
ironclaw-controlplane --dev --api-addr 127.0.0.1:8787 &
# 3. Your first command — submit a change; it is HELD at the gateway for a human decision
ironctl change submit --kind persona --group dev-agent --by you
ironctl change pending # see it waiting
ironctl change approve <change-id> --by you # apply it
```
On Windows, `irm https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.ps1 | iex`
installs the host binaries (`ironclaw-controlplane.exe` + `ironctl.exe`) and `--dev` runs, but the
**agent sandbox needs WSL2 or Linux** — see [Windows via WSL2](#windows-via-wsl2).
Version pinning, system-wide installs, and building from source are all in [Installation](#installation).
### One-click cloud deploy
Run the **hardened control-plane** on a PaaS in ~2 minutes with zero local tooling — the
approval gateway, encrypted per-session queues, host-side credential custody, and the web
console:
[](deploy/fly/)
[](https://render.com/deploy?repo=https://github.com/IronSecCo/ironclaw&path=deploy/render/render.yaml)
[](deploy/railway/)
> These PaaS paths run the **control-plane only** — a single container has no gVisor and
> no Docker socket, so **agent sandboxes don't launch there** (same boundary as the
> [hardened Compose path](deploy/docker-compose.prod.yml)). For full agent isolation use
> a gVisor host or k8s node. Details + env in the
> [deployment guide](docs/deployment.md) (**Path D**).
## CLI-first and API-first
This is a feature, not a missing dashboard. Every capability is a documented HTTP endpoint **and** an
`ironctl` subcommand, so IronClaw is scriptable, auditable, and CI-friendly from the first command —
with **no public web surface to phish, misconfigure, or leave exposed.** (There is now a private,
mesh-only web console at `/ui/` — but it's **additive, never the only way in**, and rides the same
Tailscale-bound API, so it adds no public port.)
---
<details>
<summary><b>Table of contents</b></summary>
- [Get running in under two minutes](#get-running-in-under-two-minutes)
- [CLI-first and API-first](#cli-first-and-api-first)
- [Why it's different](#why-its-different)
- [How it works](#how-it-works)
- [Platform support](#platform-support)
- [Project status](#project-status)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Quickstart](#quickstart)
- [Audit your own sandbox](#audit-your-own-sandbox-in-10-seconds)
- [Examples](#examples)
- [Usage](#usage)
- [Model providers](#model-providers)
- [Configuration](#configuration)
- [Development](#development)
- [Repository layout](#repository-layout)
- [Security](#security)
- [Roadmap](#roadmap)
- [Community](#community)
- [Contributing](#contributing)
- [License](#license)
</details>
## Why it's different
| Pillar | What it is | Attack surface it removes |
|--------|------------|----------------------------|
| **Sealed runtime** | The agent ships as a compiled Go binary | Agent self-modification — there's no source inside the box to rewrite |
| **Approved by humans** | Every change to the harness clears a deterministic gateway | Silent setting changes — nothing changes without a human seeing and approving it |
| **Encrypted queues** | Per-session encrypted message queues; read-only inbound | Data theft at rest, and cross-session reads |
| **Sealed sandbox** | gVisor container, no network, host-proxied model calls | Data exfiltration and sandbox escape |
| **Private control panel** | Admin access over a private mesh (Tailscale) only | Remote attacks on the controls |
The throughline: **treat the agent as untrusted, and make the security boundary something you can
verify — not something you take on faith.**
> ⚖️ **Weighing your options?** See [**Why IronClaw / vs. the alternatives**](https://ironsecco.github.io/ironclaw/comparison/)
> for an honest comparison against hosted agent platforms, raw container + LLM glue, and other
> self-hosted agent runtimes.
## Audit your own sandbox in 10 seconds
Do not take our word for any of that. `ironctl scan` grades the containment posture of
**any** running container, docker-compose service, or Kubernetes pod on a 0 to 100 scale.
It works on your own setups, not just IronClaw's, so you can measure how much isolation you
actually have before you hand a sandbox to untrusted code. It is fail-closed: any boundary
it cannot observe is scored insecure, never waved through.
```bash
ironctl scan my-container
```
It also grades a **Dockerfile** statically, at authoring or CI time, with no daemon and no
image pull, so you catch a leaked credential, an unpinned base, or a root default in review
instead of in production:
```bash
ironctl scan --dockerfile Dockerfile --min-score 80
```
Grade Dockerfiles automatically on every commit with the
[pre-commit](https://pre-commit.com) hook, which builds `ironctl` from source, so
there is nothing to install first:
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/IronSecCo/ironclaw
rev: v0.1.x
hooks:
- id: ironclaw-scan-dockerfile
args: [--min-score=80] # fail the commit below grade B
```
Walkthrough:
[How to scan a Dockerfile for security issues](https://ironsecco.github.io/ironclaw/blog/scan-a-dockerfile-for-security-issues/)
takes a deliberately bad Dockerfile from **5/100 (F)** to **100/100 (A)**, one fix at a time.
A container started the usual way (root user, default caps, bridge network, `docker.sock`
mounted in) grades **23/100, F**. An IronClaw `ic-sbx-*` session sandbox grades a clean
**100/100, A**:
| Target | Score | Grade | Posture |
|---|---|---|---|
| Typical `docker run` container | 23/100 | F | runs as root, `docker.sock` mounted, writable rootfs, bridge egress |
| IronClaw session sandbox | 100/100 | A | non-root, all caps dropped, seccomp on, `network=none`, read-only rootfs, gVisor |
Every failing line names the specific hole and why it matters. Drop the grade into your own
README with `ironctl scan --badge scan.svg`, or gate CI with `ironctl scan --min-score 90`.
See the [scan reference](https://ironsecco.github.io/ironclaw/scan/) for all seven dimensions
and every flag. Or browse the
[Container Isolation Scores directory](https://ironsecco.github.io/ironclaw/scores/): the
default-config grade for 150+ of the most-pulled public images, so you can see how the
containers you already run stack up. Rankings live on the
[Container Isolation Leaderboard](https://ironsecco.github.io/ironclaw/scores/leaderboard/)
(Hall of Fame vs worst offenders), and the interactive
[scores explorer](https://nivardsec.com/scores) lets you filter and grab a badge for your repo.
Head-to-head reads backed by the same scan data:
[Alpine vs Debian vs Ubuntu](https://ironsecco.github.io/ironclaw/blog/alpine-vs-debian-vs-ubuntu-container-isolation/)
(does the base image change isolation?),
[Docker default vs hardened](https://ironsecco.github.io/ironclaw/blog/docker-default-vs-hardened-container-isolation/)
(the 48-point gap, flag by flag), and
[gVisor vs runc](https://ironsecco.github.io/ironclaw/blog/gvisor-vs-runc-container-isolation-compared/)
(when a shared host kernel is the weak link).
Per-image hardening walkthroughs, the default grade, the dimensions that fail, and the exact
`ironctl scan --fix` flags that close the gap. Start at the
[**hardening guides hub**](https://ironsecco.github.io/ironclaw/blog/hardening-guides/), or jump to one:
[Postgres](https://ironsecco.github.io/ironclaw/blog/harden-postgres-container-isolation/),
[MySQL](https://ironsecco.github.io/ironclaw/blog/harden-mysql-container-isolation/),
[MariaDB](https://ironsecco.github.io/ironclaw/blog/harden-mariadb-container-isolation/),
[MongoDB](https://ironsecco.github.io/ironclaw/blog/harden-mongodb-container-isolation/),
[Cassandra](https://ironsecco.github.io/ironclaw/blog/harden-cassandra-container-isolation/),
[ClickHouse](https://ironsecco.github.io/ironclaw/blog/harden-clickhouse-container-isolation/),
[Redis](https://ironsecco.github.io/ironclaw/blog/harden-redis-container-isolation/),
[Memcached](https://ironsecco.github.io/ironclaw/blog/harden-memcached-container-isolation/),
[Elasticsearch](https://ironsecco.github.io/ironclaw/blog/harden-elasticsearch-container-isolation/),
[Kafka](https://ironsecco.github.io/ironclaw/blog/harden-kafka-container-isolation/),
[RabbitMQ](https://ironsecco.github.io/ironclaw/blog/harden-rabbitmq-container-isolation/),
[Vault](https://ironsecco.github.io/ironclaw/blog/harden-vault-container-isolation/),
[Consul](https://ironsecco.github.io/ironclaw/blog/harden-consul-container-isolation/),
[MinIO](https://ironsecco.github.io/ironclaw/blog/harden-minio-container-isolation/),
[nginx](https://ironsecco.github.io/ironclaw/blog/harden-nginx-container-isolation/),
[Grafana](https://ironsecco.github.io/ironclaw/blog/harden-grafana-container-isolation/),
[Prometheus](https://ironsecco.github.io/ironclaw/blog/harden-prometheus-container-isolation/),
[Traefik](https://ironsecco.github.io/ironclaw/blog/harden-traefik-container-isolation/),
[InfluxDB](https://ironsecco.github.io/ironclaw/blog/harden-influxdb-container-isolation/),
[CockroachDB](https://ironsecco.github.io/ironclaw/blog/harden-cockroachdb-container-isolation/),
[TimescaleDB](https://ironsecco.github.io/ironclaw/blog/harden-timescaledb-container-isolation/),
[Valkey](https://ironsecco.github.io/ironclaw/blog/harden-valkey-container-isolation/),
[HAProxy](https://ironsecco.github.io/ironclaw/blog/harden-haproxy-container-isolation/),
[ZooKeeper](https://ironsecco.github.io/ironclaw/blog/harden-zookeeper-container-isolation/) and
[untrusted Node.js](https://ironsecco.github.io/ironclaw/blog/run-untrusted-nodejs-code-safely/).
### Grade every pull request
The same grader is published on the GitHub Marketplace as
[**IronClaw sandbox scan**](https://github.com/marketplace/actions/ironclaw-sandbox-scan). One
line in a workflow and every pull request gets a containment scorecard as a sticky comment that
updates in place:
```yaml
# .github/workflows/scan.yml
- uses: IronSecCo/ironclaw@v1
with:
target: docker-compose.yml
mode: compose
min-score: 90 # omit / 0 = report-only, never blocks the check
```
It runs on a stock `ubuntu-latest` runner with no credentials and no control-plane. `mode: k8s`
adds `policy-check: true` to fail the check on any rule `--emit-policy` would generate, and
`upload-sarif: true` sends failed dimensions to the Security tab. Full inputs and outputs:
[scan in CI](https://ironsecco.github.io/ironclaw/scan-action/).
### Show your score
Put your containment grade in your README, the same way a coverage or build badge does.
Generate a shields.io endpoint file, commit it (no server, so a badge hit never triggers a
remote scan), and embed it:
```bash
ironctl scan my-container --badge-json .ironclaw/sandbox-isolation.json
git add .ironclaw/sandbox-isolation.json && git commit -m "chore: add sandbox isolation badge"
```
```markdown
[](https://ironsecco.github.io/ironclaw/scan/)
```
The badge at the top of this README is IronClaw's own, graded 100/100 A from
[`.ironclaw/sandbox-posture.yml`](.ironclaw/sandbox-posture.yml). Full walkthrough:
[Add a live Sandbox Isolation Score badge to your repo](https://ironsecco.github.io/ironclaw/blog/add-a-sandbox-isolation-score-badge-to-your-repo/).
## How it works
Two compiled Go programs that never share memory and talk only through a pair of encrypted SQLite
files per conversation:
```mermaid
flowchart TB
CHAT["Chat platforms<br/>12 channel adapters"]
CLI["ironctl CLI"]
WEB["Web console"]
subgraph host["Trusted host — control-plane (cmd/controlplane)"]
API["HTTP API<br/>Tailscale mesh-only + bearer"]
GW["Gateway<br/>deterministic verifiers · human approval"]
CORE["Router · delivery · sweep · key custodian"]
CHAD["Channel adapters"]
MP["Model proxy<br/>holds provider keys"]
ISO["Isolation launcher<br/>gVisor / runsc"]
end
subgraph queues["Encrypted SQLCipher queues · per session"]
INQ[("inbound.db<br/>read-only to agent")]
OUTQ[("outbound.db<br/>append-only by agent")]
end
subgraph box["Agent sandbox · gVisor · network=none"]
LOOP["Agent loop · tools · model provider"]
end
PROV["Model providers<br/>Anthropic · OpenAI · OpenRouter"]
CHAT <--> CHAD
CLI -->|mesh only| API
WEB -->|mesh only| API
CHAD --> CORE
API --> GW --> CORE
CORE -->|write| INQ
OUTQ -->|read| CORE
ISO -->|launch| LOOP
INQ -->|ro bind mount| LOOP
LOOP -->|append| OUTQ
LOOP -->|unix socket| MP -->|HTTPS · key injected host-side| PROV
classDef host fill:#eaf2ff,stroke:#1d4ed8,stroke-width:1px,color:#0b1124;
classDef store fill:#b9d4ff,stroke:#1d4ed8,stroke-width:1px,color:#0b1124;
classDef box fill:#1d4ed8,stroke:#63a0ff,stroke-width:2px,color:#ffffff;
classDef control fill:#16224a,stroke:#63a0ff,stroke-width:2px,color:#ffffff;
classDef ext fill:#f4f9ff,stroke:#8fb4ff,stroke-width:1px,color:#16224a;
class API,CORE,CHAD,MP,ISO host;
class GW control;
class INQ,OUTQ store;
class LOOP box;
class CHAT,CLI,WEB,PROV ext;
```
- The **control-plane** receives chats, routes them, holds the keys, runs the approval gateway, and
performs every privileged action on the agent's behalf — after its own checks.
- The **sandbox** — one per conversation, wrapped in gVisor with no network of its own — reads its
encrypted inbox (read-only), calls the AI model through the host proxy, and writes its encrypted
outbox. It can *request* a capability change but can never apply one.
- The **frozen contract** (`internal/contract`) is the only package both sides import: typed IDs,
row shapes, the embedded SQL schema, pinned cipher params, and the gateway protocol.
A single message rides a clean loop; anything that would change what the agent *can do* takes the
separate dashed path through the human-approval **gateway**:
```mermaid
flowchart LR
SENDER["External sender<br/>Slack · email · …"]
ADAPTER["Channel adapter"]
ROUTER["Router<br/>authorize + fan-out"]
INQ[("inbound.db")]
LOOP["Agent loop"]
MODEL["Model provider"]
OUTQ[("outbound.db")]
DELIVERY["Delivery"]
GW{"Gateway<br/>human approval"}
APPLY["Control-plane<br/>applies change"]
SENDER -->|message| ADAPTER --> ROUTER
ROUTER -->|write · encrypted| INQ
INQ -->|ro| LOOP
LOOP <-->|model call via host proxy| MODEL
LOOP -->|reply · append| OUTQ
OUTQ --> DELIVERY --> ADAPTER
ADAPTER -->|reply| SENDER
LOOP -.->|capability-change request| GW
GW -.->|approved| APPLY
classDef host fill:#eaf2ff,stroke:#1d4ed8,stroke-width:1px,color:#0b1124;
classDef store fill:#b9d4ff,stroke:#1d4ed8,stroke-width:1px,color:#0b1124;
classDef box fill:#1d4ed8,stroke:#63a0ff,stroke-width:2px,color:#ffffff;
classDef control fill:#16224a,stroke:#63a0ff,stroke-width:2px,color:#ffffff;
classDef ext fill:#f4f9ff,stroke:#8fb4ff,stroke-width:1px,color:#16224a;
class ADAPTER,ROUTER,DELIVERY,APPLY host;
class INQ,OUTQ store;
class LOOP box;
class GW control;
class SENDER,MODEL ext;
```
For the full design, see [`docs/architecture.md`](docs/architecture.md),
[`docs/threat-model.md`](docs/threat-model.md), and the plain-language tour in
[`docs/ironclaw-explained.md`](docs/ironclaw-explained.md).
> 📚 **Full documentation site:** [**ironsecco.github.io/ironclaw**](https://ironsecco.github.io/ironclaw/)
> — quickstart, architecture, threat model, channels, skills, the OpenAPI reference, and security,
> all in one navigable place (built from `docs/` and published on every push to `main`).
>
> 🧭 **New here?** The [**hands-on tutorials**](https://ironsecco.github.io/ironclaw/tutorials/) take
> you from `git clone` to a running agent: [your first sandboxed agent in 5 minutes](https://ironsecco.github.io/ironclaw/tutorials/first-agent/),
> [connecting Slack](https://ironsecco.github.io/ironclaw/tutorials/connect-slack/), and
> [writing a custom channel adapter](https://ironsecco.github.io/ironclaw/tutorials/custom-channel-adapter/).
## Platform support
IronClaw's security model rests on **gVisor** (`runsc`) — a user-space kernel that intercepts the
agent's Linux syscalls and is the layer that actually *enforces* `network=none`, the seccomp
syscall allowlist, dropped Linux capabilities, and a read-only rootfs. **gVisor is Linux-only**, and
that one fact drives the whole platform story:
| Capability | Linux + gVisor (production target) | macOS / Windows |
|---|---|---|
| Host side — control-plane, gateway, API, `ironctl`, web console | ✅ native | ✅ native (incl. native Windows) |
| Real agent sandbox | ✅ gVisor (`runsc`) | ⚠️ `--runtime docker` only — runc in Docker Desktop's Linux VM. **macOS:** Docker Desktop. **Windows:** WSL2 (native Windows can't reach it — see below) |
| Per-sandbox syscall interception | ✅ | ❌ not available |
| Seccomp syscall allowlist | ✅ enforced | ❌ not applied on the Docker path |
| `network=none` | ✅ enforced by the OCI spec | ⚠️ **not auto-enforced** — you must point `IRONCLAW_DOCKER_NETWORK` at a no-egress network |
| Dropped capabilities · read-only rootfs | ✅ enforced by the runtime | ⚠️ only as strong as the Docker Desktop VM kernel |
**On macOS** you can build, script, demo, and develop against the entire system natively, and you
can even run agents through Docker Desktop — but understand that the sandbox boundary then comes from
**runc inside the Docker Desktop Linux VM, not gVisor.** There is no per-sandbox syscall
interception, the curated seccomp profile is not applied, and `network=none` is not enforced for you
(the Docker isolator passes whatever network you configure straight through — set
`IRONCLAW_DOCKER_NETWORK` to a no-egress bridge yourself). That is **weaker than the posture the
[threat model](docs/threat-model.md) assumes.**
### Windows via WSL2
The `install.ps1` PowerShell installer gives you the **host plane** natively on Windows:
`ironclaw-controlplane.exe` and `ironctl.exe` run, the encrypted SQLCipher queue works, and `--dev`
mode (no real sandbox) runs end-to-end. **A real agent sandbox does not run on native Windows** —
gVisor (`runsc`) is Linux-only, and the Docker fallback talks to the Docker Engine over a **Unix**
socket (`/var/run/docker.sock`), which native Windows Docker Desktop does not expose (it serves a
Windows named pipe instead). So on native Windows you get the control plane and `ironctl`, but the
agent runtime has nowhere to launch.
**To actually run agents on Windows, use WSL2:**
```powershell
wsl --install -d Ubuntu # one-time: install WSL2 + Ubuntu, then reboot
```
Then, **inside the WSL2 Ubuntu shell**, install the Linux build and run it exactly as on Linux:
```sh
curl -fsSL https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.sh | sh
```
Inside WSL2, `/var/run/docker.sock` is present (Docker Desktop's WSL integration, or Docker installed
in the distro), so `IRONCLAW_RUNTIME=docker` launches real Linux sandbox containers. For the full
gVisor posture, install `runsc` inside the WSL2 distro just as you would on bare-metal Linux. Treat a
WSL2 host the same as the Linux row above.
**For anything past local development, run the sandbox host on Linux with gVisor** (bare-metal,
a VM, or WSL2). The control plane can live wherever you like — including native Windows — but it's
the agent sandbox that needs the Linux + gVisor substrate to give you the boundary IronClaw is built
around.
## Project status
**Alpha.** The architecture is settled and the full control-plane and sandbox pipelines are
implemented and tested. The encrypted-queue binding is now wired:
- **Encrypted-SQLite queue binding** — ✅ wired (**RFC-0001 applied**). `contract.Open*` open
per-session SQLCipher databases via cgo (`github.com/mutecomm/go-sqlcipher/v4`); a round-trip test
covers write→read, read-only-write rejection, wrong-key failure, and no-plaintext-on-disk. The
build now requires `CGO_ENABLED=1` (a C toolchain). `internal/host/queue` uses the live binding;
in-memory backends remain for `--dev` and tests.
- **Sandbox rootfs provisioning** — ✅ wired via a pluggable provisioner: `isolation` builds the
hardened OCI spec, provisions the bundle rootfs (with image digest/signature verification against a
trust policy), and execs `runsc`. A real launch still needs `runsc` and a provisioned/signed image
present in the environment.
- **Production hardening (Wave 4)** — durable/pluggable master-key custody, a Prometheus `/metrics`
surface, structured logging, host respawn + sandbox provider backoff, and model-proxy rate
caps/audit/redaction have landed **and are composed into `cmd/controlplane`**. The API-server
hardening knobs (optional TLS, rate-limit, body limits, `/readyz` readiness gate) exist as
`api.With*` options but aren't attached in the entrypoint yet (see the [roadmap](#roadmap)).
See the [roadmap](#roadmap) for what remains. You can build, test, and run the control-plane today;
a live sandbox launch needs `runsc` plus a provisioned image.
## Prerequisites
| Requirement | For | Notes |
|-------------|-----|-------|
| **Go 1.23+ and a C toolchain** | building everything | `CGO_ENABLED=1` is required — the encrypted-SQLite binding builds via cgo |
| **containerd + gVisor (`runsc`)** | production sandboxing | runtime `io.containerd.runsc.v1`; not needed for `--dev` |
| **Tailscale** | remote admin access | the control-plane API binds to the tailnet IP; no public port |
| **SQLCipher (vendored)** | encrypted queues | the SQLCipher C amalgamation is vendored by the driver; no system lib needed |
| **A model credential** | live model calls | an Anthropic / OpenAI / OpenRouter key, or a gateway like OneCLI — injected host-side into the model proxy, never into the sandbox ([Model providers](#model-providers)) |
The three external runtime dependencies (gVisor, Tailscale, the encrypted-SQLite binding) are
intentionally **not vendored**. See [`deploy/README.md`](deploy/README.md) for host setup.
## Installation
### Homebrew (macOS / Linux)
```sh
brew tap IronSecCo/ironclaw https://github.com/IronSecCo/ironclaw
brew install ironsecco/ironclaw/ironclaw
```
This installs `ironctl`, `ironclaw-controlplane`, and `ironclaw-sandbox` from the release the
formula currently pins. The formula pins each archive to the SHA-256 recorded in that release's
signed `SHA256SUMS`, so Homebrew verifies the download before installing. Confirm it with
`ironctl version`.
The tap carries exactly one version at a time. An automated pull request bumps the formula after
every release, and it lands only once a required CI check has re-derived the formula from that
release's cosign-verified `SHA256SUMS`, so the tap can briefly trail the newest release. Run
`brew update` first, and see
[Releases](https://github.com/IronSecCo/ironclaw/releases/latest) for the newest version. To
install a specific version, including one the tap has not picked up yet, use the installer
script's `IRONCLAW_VERSION` ([below](#prebuilt-binaries-installer-script)).
> **Use the fully-qualified name.** homebrew-core ships an *unrelated* formula also called `ironclaw`,
> and core wins the bare name — so install `ironsecco/ironclaw/ironclaw`, not bare `ironclaw`. The
> explicit tap URL is required too: our tap lives in this repo, not a `homebrew-ironclaw` repo.
> In production the control plane usually runs as the GHCR container image (see the
> [deployment guide](https://ironsecco.github.io/ironclaw/deployment/)); the native
> `ironclaw-controlplane` binary is convenient for local / `--dev` runs.
### Prebuilt binaries (installer script)
One command installs the latest release — `ironctl` and `ironclaw-controlplane`. The script
detects your OS/arch, downloads the matching archive from
[GitHub Releases](https://github.com/IronSecCo/ironclaw/releases), and verifies its SHA-256
checksum before installing.
**macOS / Linux**
```sh
curl -fsSL https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.sh | sh
```
**Windows (PowerShell)**
```powershell
irm https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.ps1 | iex
```
> This installs the **host binaries** (`ironclaw-controlplane.exe` + `ironctl.exe`) and runs `--dev`
> natively, but it **cannot run a real agent sandbox** — that needs Linux. To run agents on Windows,
> install inside **WSL2**; see [Windows via WSL2](#windows-via-wsl2).
A fresh release is published on every push to `main`, with prebuilt archives for:
| OS | Architectures |
|----|---------------|
| macOS | Intel (`amd64`) · Apple Silicon (`arm64`) |
| Linux | `amd64` · `arm64` |
| Windows | `amd64` |
The installer reads a few environment variables (pass them on the `sh` side of the pipe):
```sh
# Pin a version instead of latest
curl -fsSL https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.sh | IRONCLAW_VERSION=v0.1.102 sh
# Install system-wide (a normal user defaults to ~/.local/bin)
curl -fsSL https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.sh | sudo sh
# Choose the install directory
curl -fsSL https://raw.githubusercontent.com/IronSecCo/ironclaw/main/scripts/install.sh | IRONCLAW_BINDIR="$HOME/bin" sh
```
Then confirm what you installed:
```sh
ironctl --version
```
Prefer to grab files by hand? Download the archive and `SHA256SUMS` for your platform from the
[latest release](https://github.com/IronSecCo/ironclaw/releases/latest).
### Version managers (mise / asdf)
Pin IronClaw per project with [mise](https://mise.jdx.dev) or [asdf](https://asdf-vm.com), no
account and no `sudo`. The quickest path uses mise's `ubi` backend to install the `ironctl` CLI
straight from the GitHub release (no plugin repo):
```sh
mise use -g "ubi:IronSecCo/ironclaw[exe=ironctl]@latest"
ironctl --version
```
For both host binaries (`ironctl` + `ironclaw-controlplane`) and a pinned `.tool-versions`, use the
asdf-style plugin under [`packaging/asdf-ironclaw/`](packaging/asdf-ironclaw/). It downloads the
release tarball, verifies it against the published `SHA256SUMS`, and drops both binaries on the
managed PATH:
```text
ironclaw 0.1.217
```
The plugin resolves as a standalone repo (asdf clones plugins by URL), so `asdf plugin add ironclaw`
and `mise use asdf:...` become available once the plugin lands in its own `IronSecCo/asdf-ironclaw`
repository. Until then the scripts in `packaging/asdf-ironclaw/` are runnable directly (see that
directory's `README.md`).
### Verifying a release
Releases are **signed and attested** — a keyless [cosign](https://docs.sigstore.dev/)
signature over `SHA256SUMS`, an **SBOM** (SPDX + CycloneDX), and build-provenance attestations for
every archive and the container image. For how releases are cut, verified, and yanked, see the
[release runbook](docs/release-runbook.md).
<details>
<summary>Verifying a signed release</summary>
Each release carries `SHA256SUMS` plus `SHA256SUMS.sig` + `SHA256SUMS.pem` (the cosign signature and
its certificate), `*.spdx.json` / `*.cdx.json` SBOMs, and per-archive + image attestations.
Verify the checksum signature (no key to manage — the identity is the release workflow):
```sh
cosign verify-blob SHA256SUMS \
--signature SHA256SUMS.sig --certificate SHA256SUMS.pem \
--certificate-identity-regexp '^https://github.com/IronSecCo/ironclaw/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
sha256sum -c SHA256SUMS # then confirm your archive matches
```
Verify build provenance for an archive, an extracted binary, or the image:
```sh
gh attestation verify ironclaw_<version>_<platform>.tar.gz --repo IronSecCo/ironclaw
gh attestation verify ./ironctl --repo IronSecCo/ironclaw # a binary extracted from the archive
gh attestation verify oci://ghcr.io/ironsecco/ironclaw-controlplane:latest --repo IronSecCo/ironclaw
```
The container image also carries a signed **SBOM attestation** (CycloneDX) you can verify
and read anonymously:
```sh
gh attestation verify oci://ghcr.io/ironsecco/ironclaw-controlplane:latest \
--repo IronSecCo/ironclaw \
--predicate-type https://cyclonedx.org/bom
```
Every third-party GitHub Action is **pinned to a commit SHA**, builds use a **pinned
toolchain + `-trimpath`** and are checked for **bit-for-bit reproducibility** by a
double-build CI job (`ironctl` and `sandbox` are verified byte-identical; the larger
control-plane binary is reproducible under newer Go and tracked for the pinned toolchain),
and the project's supply-chain posture is scored continuously by
[OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/IronSecCo/ironclaw) (see the badge above).
</details>
### From source
Requires Go 1.23+ and a C toolchain (`CGO_ENABLED=1` — the encrypted-SQLite binding builds via cgo).
```sh
# Clone
git clone https://github.com/IronSecCo/ironclaw.git
cd ironclaw
# Build all binaries
make build # == go build ./...
# Or install the two host binaries onto your PATH
go build -o /usr/local/bin/ironclaw-controlplane ./cmd/controlplane
go build -o /usr/local/bin/ironctl ./cmd/ironctl
```
For a full system install — build and install the binaries, provision `/etc/ironclaw`
and `/var/lib/ironclaw`, and enable the service (systemd on Linux, launchd on macOS) —
run [`sudo deploy/install.sh`](deploy/install.sh). It needs root to write under `/etc`
and `/var/lib`. The external runtime dependencies it relies on (containerd + gVisor and
Tailscale) are set up separately — see [`deploy/README.md`](deploy/README.md).
### With Docker (`docker compose`)
Self-host the control-plane in one command. From a clone:
```sh
cp .env.example .env # fill in ANTHROPIC_API_KEY (optional to boot)
docker compose up -d # builds locally on first run, or pulls the GHCR image
docker compose logs -f controlplane # CLAIM the admin token printed once on first run
```
The admin/API token is **minted on first run and printed once** in the logs (there is
no recovery) unless you set `IRONCLAW_API_TOKEN` yourself. The admin API is published
on `127.0.0.1:8787` only — front it with Tailscale for remote access.
Prefer the published image? It is pushed to GitHub Container Registry on every release:
```sh
docker pull ghcr.io/ironsecco/ironclaw-controlplane:latest
# or pin a release: docker pull ghcr.io/ironsecco/ironclaw-controlplane:v0.1.102
```
Set `IRONCLAW_IMAGE` in `.env` to pin that tag for `docker compose`. Every variable the
control-plane reads is documented in [`.env.example`](.env.example). The agent sandboxes
themselves are **not** compose services — the control-plane launches them as gVisor
(`runsc`) children with `network=none`; running real sandboxes needs a runsc-capable
host (see [`deploy/README.md`](deploy/README.md)).
Going to production? The **[deployment guide](https://ironsecco.github.io/ironclaw/deployment/)**
covers the hardened, durable posture: locked-down [`deploy/docker-compose.prod.yml`](deploy/docker-compose.prod.yml)
(read-only rootfs, dropped caps, resource limits) behind a TLS reverse proxy
([`deploy/Caddyfile`](deploy/Caddyfile)), secrets via an env-file, encrypted-state
backup/restore, pinned-digest upgrades, and Prometheus `/metrics`.
## Quickstart
A fuller local walkthrough — run the control-plane **from source** in dev mode (no gVisor, binds to
loopback) and drive it with the admin CLI:
```sh
# Terminal 1 — start the control-plane in dev mode
export ANTHROPIC_API_KEY=sk-ant-... # held host-side; never enters the sandbox
export IRONCLAW_API_TOKEN=$(openssl rand -hex 32)
go run ./cmd/controlplane --dev --api-addr 127.0.0.1:8787
# Terminal 2 — talk to the gateway with ironctl
export IRONCLAW_API_TOKEN=<same token as above>
# Submit a capability change — it is HELD pending a human decision (the gateway choke point)
ironctl change submit --kind persona --group dev-agent --by alice
# See what's waiting for approval, then approve or reject by id
ironctl change pending
ironctl change approve <change-id> --by alice
# Inspect the append-only audit log
ironctl audit --limit 20
```
Every mutation — persona, enabled tools, packages, wiring, permissions, mounts — flows through this
same gateway. There is no file-edit path that bypasses it.
## Examples
Two of them run **end to end with zero credentials** — no model key, no channel tokens,
just Docker. Copy one line and watch it work:
<div align="center">
<img src="docs/assets/hello.svg" width="760" alt="hello-ironclaw terminal demo: one command (examples/hello-ironclaw/run.sh) builds the sandbox image, starts the offline mock-agent control-plane with no credentials, sends a chat through the real engage → per-session sandbox → encrypted queue → reply path, and prints PASS when the agent's reply comes back.">
<sub><b><a href="examples/hello-ironclaw/">hello-ironclaw</a> — the canonical "it works."</b> One command sends a chat through the <b>real</b> secured path (engage → per-session sandbox → encrypted queue → reply) and asserts the reply returns. Zero credentials; doubles as the CI smoke test. Animation freezes on the final frame under <code>prefers-reduced-motion</code>.</sub>
<br><br>
<img src="docs/assets/containment.svg" width="760" alt="live-containment terminal demo (final frame): a fully-jailbroken agent tries three escapes from inside the sandbox and each is BLOCKED — network exfil denied by network=none (only loopback), host filesystem read denied by the mount namespace, host takeover via the Docker Engine socket denied because the socket is never mounted in — ending with a containment summary that 3 of 3 escape attempts were denied and the box held.">
<sub><b><a href="examples/live-containment/">live-containment</a> — watch it catch a real escape.</b> The 60-second security aha: one command engages a real sandbox, a fully-jailbroken agent <b>tries to break out</b> (network exfil, host-filesystem breakout, host takeover via the Docker socket), and your terminal shows each attempt <b>denied</b> plus a containment summary. The curated cut of <code>red-team-escape</code>. Zero credentials.</sub>
<br><br>
<img src="docs/assets/redteam.svg" width="760" alt="red-team-escape terminal demo: assuming a fully jailbroken agent, the harness runs an escape battery from inside the sandbox and prints a PASS table — network egress blocked (interfaces: lo), Docker socket absent, no sibling orchestration, host root not mounted, self-modification held at the gateway, host master and sibling keys unreachable — then reports every core containment assertion held.">
<sub><b><a href="examples/red-team-escape/">red-team-escape</a> — isolation you can prove.</b> The full six-assertion battery behind <code>live-containment</code>: adds sibling-breakout and cross-session key-custody probes and emits a <b>signed, versioned containment report</b>; runs as the CI containment gate on every push. Zero credentials.</sub>
</div>
Runnable recipes live in [`examples/`](examples/) — each is a directory with a `README.md` and a `setup.sh`.
Three of them ship a `run-mock.sh` that drives the **whole** inbound → agent → reply pipeline on the
offline `mock` provider, so a fresh clone runs them with **no model key and no channel tokens**:
```sh
docker compose -f docker-compose.demo.yml up -d --build # seeds the offline mock-agent
./examples/scheduled-report/run-mock.sh # cron-style self-scheduling summary
./examples/webhook-responder/run-mock.sh # inbound webhook → agent reply
./examples/slack-triage/run-mock.sh # classify/label every message
```
- [`scheduled-report/`](examples/scheduled-report/) — wakes itself on a schedule (`schedule_task`), summarizes, posts to a channel. *(credential-free demo)*
- [`webhook-responder/`](examples/webhook-responder/) — routes an inbound HTTP webhook to an agent that replies. *(credential-free demo)*
- [`slack-triage/`](examples/slack-triage/) — classifies/labels every incoming Slack message. *(credential-free demo)*
- [`personal-assistant/`](examples/personal-assistant/) — a private 1:1 assistant on Telegram, plus a walk-through of the mandatory change-approval flow.
- [`channel-triage/`](examples/channel-triage/) — a Slack triage bot that engages only on `@mention`, only for known senders.
- [`multi-agent-team/`](examples/multi-agent-team/) — two agents sharing one channel, separated by engage mode and priority.
## Usage
### `ironclaw-controlplane` — the host daemon
```sh
ironclaw-controlplane \
--api-addr "$(tailscale ip -4):8787" \ # bind to the tailnet IP (no public port)
--model-proxy-socket /run/ironclaw/modelproxy.sock \
--runtime runsc \ # container runtime for sandboxes
--state-dir /var/lib/ironclaw \
--sweep-interval 60s
```
| Flag | Default | Purpose |
|------|---------|---------|
| `--api-addr` | `127.0.0.1:8787` | control-plane API address; set to the tailnet IP in production |
| `--model-proxy-socket` | `/run/ironclaw/modelproxy.sock` | unix socket bound into each sandbox for model egress |
| `--state-dir` | OS-specific | gateway change store, audit log, keystore |
| `--runtime` | `runsc` | OCI runtime for sandboxes |
| `--bundle-root` | `<state-dir>/bundles` | per-session OCI bundles |
| `--sweep-interval` | `60s` | stale-sandbox / due-message sweep cadence |
| `--egress-socket` | `""` (sealed) | opt-in: host unix socket for the egress broker, bound into each sandbox so an agent can reach **approved** external hosts (deny-by-default, audited) |
| `--egress-allow` | `""` | comma-separated hostnames the egress broker permits (only with `--egress-socket`) |
| `--search-backend` | `""` (off) | give each sandbox the `web_search` tool: `duckduckgo` (keyless) or `brave[:cred]` (keyed via the vault). Requires `--egress-socket`; the backend's host is auto-added to the allowlist |
| `--mcp-catalog` | `""` (off) | opt-in: enable MCP servers — a per-session host broker, the `mcp_access` change kind, and the **MCP** console tab. The 0600 JSON catalog of configured servers |
| `--mcp-isolation` | `container` | how **local** (stdio) MCP servers run: `container` (hardened, `network=none` — production) or `none` (bare host process — dev only) |
| `--mcp-runtime` / `--mcp-image` | `""` | OCI runtime (e.g. `runsc` for gVisor) and default image for isolated local MCP servers |
| `--dev` | `false` | loopback bind, no gVisor — local development only; also opens a **DuckDuckGo-only** egress path so `web_search` works out of the box, and enables MCP with `--mcp-isolation=none` |
#### MCP servers
Extend an agent with the tools of a **Model Context Protocol** server — local (a stdio
subprocess) or remote (an HTTPS endpoint) — without weakening the sandbox. MCP runs
**host-side only**: a local server is isolated in a hardened `network=none` container, a
remote one is dialed over TLS, and the sandbox reaches neither directly — it talks to a
**per-session broker socket** where every call is checked against a **per-tool,
human-approved grant** and audited. This closes the "blind MCP approval" gap the
reference design had. Enable it with `--mcp-catalog`, add servers + grant agents on the
console's **MCP** tab, and try it end to end with the bundled `cmd/mcp-sample` server.
Full guide: [docs/mcp.md](docs/mcp.md).
To expose IronClaw's `sandbox_exec` tool to Claude Desktop, Cursor, or Windsurf
as an MCP server, see [docs/mcp-server/](docs/mcp-server/index.md).
Environment: `ANTHROPIC_API_KEY` (model proxy credential, host-only) and `IRONCLAW_API_TOKEN`
(bearer token required on every API call when set).
#### Web search
The sandbox is `network=none`; it can only reach hosts through the host-mediated, audited
egress broker. The `web_search` tool rides that broker, so it is **off by default** and turns
on only with both `--egress-socket` and `--search-backend`:
- `duckduckgo` — keyless, no secret. The quickest way to a working search, but DuckDuckGo's
keyless API returns instant answers / related topics rather than a full ranked web index, so
specific lookups (e.g. a person's name) can come back thin.
- `brave[:cred]` — Brave Search reached **by name** through the credential vault
(`vault://<cred>/…`), so the API key stays host-side in the injector and never enters the
sandbox. Requires `--vault-endpoint` with a matching credential.
`--dev` enables the DuckDuckGo backend automatically (placing the egress socket next to the
model-proxy socket so it rides the same sandbox mount). Under the Docker isolator, make sure the
directory holding those sockets is in `IRONCLAW_DOCKER_BINDS` so the sandbox can reach it.
### `ironctl` — the admin CLI
A thin client of the control-plane API. `--addr` defaults to `http://127.0.0.1:8787`; the bearer
token comes from `IRONCLAW_API_TOKEN` or `--token`.
```sh
ironctl change submit --kind <k> --group <g> --by <user> # k: persona|enabled_tools|packages|wiring|permissions|mounts
ironctl change pending # list changes awaiting a decision
ironctl change history # all changes and their outcomes
ironctl change approve <id> --by <user>
ironctl change reject <id> --by <user>
ironctl audit [--limit N] # append-only gateway audit log
```
#### Define an agent the easy way
You don't have to know tool names or hand-write JSON. Pick a starter template, tweak it, and go —
in one step, from the CLI or the web console's **Agents → Create** builder:
```sh
ironctl tools # browse every built-in tool, grouped, with descriptions
ironctl agent templates # list starter presets (assistant, researcher, …)
# Guided wizard (run in a terminal with no flags): name → template → persona → tools → confirm
ironctl agent create
# Or one-shot/scriptable — template + a couple extra tools, persona override, default model:
ironctl agent create --name "Research Bot" --template researcher --tool schedule_task
ironctl agent create --name "Helper" --template assistant --all-tools --yes
ironctl agent list # all agents, with model + live session/channel counts
ironctl agent show research-bot # persona, model, enabled tools, installed skills
```
**Persona as separate documents.** Rather than one opaque prompt, an agent's persona is split by
concern — **IDENTITY.md** (who it is), **SOUL.md** (personality/voice), and **AGENTS.md** (how it
works) — which compose into the system prompt. Set them inline, or point at a directory of those
files (the builder shows the same three fields):
```sh
ironctl agent create --name "Atlas" \
--identity "You are Atlas, a research assistant for the data team." \
--soul "Curious and precise. You cite sources and admit uncertainty." \
--instructions "Search before answering; prefer primary sources; summarize with links."
ironctl agent create --name "Atlas" --persona-dir ./atlas/ # loads IDENTITY.md / SOUL.md / AGENTS.md
```
This defines the agent (name + persona docs + model + tools) in a single operator-direct write.
Enabling a web/API tool only makes it *visible* to the agent — actual egress still requires an
approved host through the gateway, so the network posture is unchanged.
### `sandbox` — the in-sandbox agent
Launched by the control-plane's isolator, not by hand. It receives its session key and queue paths
and runs the reasoning loop. Key flags (`cmd/sandbox`): `--inbound`, `--outbound`, `--key`,
`--workspace`, `--heartbeat`, `--model-socket`, `--model-host`, `--model`.
### Control-plane HTTP API
| Method & path | Purpose |
|---------------|---------|
| `GET /healthz` | liveness (unauthenticated) |
| `POST /v1/changes` | submit a `ChangeRequest` |
| `GET /v1/changes/pending` | list pending changes |
| `GET /v1/changes/history` | list all changes |
| `POST /v1/changes/{id}/decision` | record an approve/reject decision |
| `GET /v1/audit` | read the audit log |
## Model providers
By default every agent talks to **Anthropic** (Claude). You can point an agent at **OpenAI** or
**OpenRouter** instead, or — without IronClaw holding any model key at all — route through an
operator-run **credential gateway** such as **OneCLI**, which injects the real credential at request
time. In every case the **sandbox stays `network=none` and credential-free**: it reaches the model
only through the host model-proxy unix socket, and the host proxy authenticates the call and enforces
the egress allowlist. The backend is chosen **per agent group, host-side** — a sandbox can never pick
or change its own provider.
### Direct provider keys
Set one or more keys host-side (daemon env, or `.env` for `docker compose`). A provider's upstream
host is allowlisted only when its key is present:
```sh
export ANTHROPIC_API_KEY=sk-ant-... # default / primary
export OPENAI_API_KEY=sk-... # optional
export OPENROUTER_API_KEY=sk-or-... # optional
```
### Via a credential gateway like OneCLI (ChatGPT/Codex — no key inside IronClaw)
A credential gateway is a host-local HTTP `CONNECT` proxy that holds the real credential and injects
it per request, so **neither the control-plane nor the sandbox ever sees a model key.** This is how
you power an agent with a **ChatGPT/Codex** account via **OneCLI**: IronClaw's `codex` provider
speaks the ChatGPT Codex Responses API (`chatgpt.com`) and OneCLI attaches the OAuth credential.
Run OneCLI on the host (its default address is `127.0.0.1:10255`), then point the model-proxy at it
and allowlist the host it serves:
```sh
# The gateway URL carries your per-agent OneCLI token as Basic userinfo — Go's HTTP
# client sends it as Proxy-Authorization on CONNECT. The gateway terminates TLS with
# its own CA, so upstream TLS verification is skipped (intended for a loopback gateway).
export IRONCLAW_MODEL_GATEWAY_URL="http://x:aoc_<your-onecli-agent-token>@127.0.0.1:10255"
export IRONCLAW_MODEL_GATEWAY_HOSTS="chatgpt.com"
# No ANTHROPIC_API_KEY needed — the gateway is the only credential path. Make the
# default backend Codex so every agent uses it out of the box:
export IRONCLAW_DEV_PROVIDER=codex
export IRONCLAW_DEV_MODEL=gpt-5.5
ironclaw-controlplane --api-addr 127.0.0.1:8787 # (+ your other flags)
```
When a gateway is set, don't also set a key for the host it serves — the gateway is the credential
path, and the control-plane injects nothing for the gateway's hosts. Under `docker compose` the
gateway must be reachable *from the container*, so use `host.docker.internal:10255` (Docker Desktop)
or put OneCLI and the control-plane on a shared Docker network instead of `127.0.0.1`.
### Run a 100% local model (Ollama, LM Studio, vLLM) — no cloud key
Point IronClaw at a self-hosted **OpenAI-compatible** endpoint and the whole stack runs on your own
box with **zero cloud credentials** — nothing leaves the machine. Ollama, LM Studio, vLLM, and
llama.cpp all expose the OpenAI `/v1` API (Ollama at `http://localhost:11434/v1`).
```sh
ollama pull llama3.2 # 1. run a model locally
export IRONCLAW_LOCAL_MODEL_URL=http://localhost:11434/v1
export IRONCLAW_LOCAL_MODEL=llama3.2 # 2. point IronClaw at it
ironclaw-controlplane --dev --api-addr 127.0.0.1:8787 # 3. chat — no API key
```
This allowlists the local host, forwards to it over plain HTTP (these servers serve no TLS), and
makes it the deployment-default model, so every agent group without a pinned provider runs local. No
key is required; set `IRONCLAW_LOCAL_MODEL_KEY` only for the rare local server (e.g. a guarded vLLM)
that requires one. Under `docker compose` the server must be reachable *from the control-plane
container*, so use `http://host.docker.internal:11434/v1` (Docker Desktop) instead of `localhost`.
Full walkthrough: **[Run IronClaw with a 100% local model (Ollama)](docs/tutorials/local-model-ollama.md)**.
### Choosing the provider per agent
`IRONCLAW_DEV_PROVIDER` / `IRONCLAW_DEV_MODEL` set the deployment-wide default for any agent group
that doesn't pin one (the env names keep their `DEV_` prefix but apply deployment-wide). To choose
per agent instead — a gateway-approved change, like any other config:
```sh
ironctl agent create --name "Codex Bot" --provider codex --model gpt-5.5
ironctl agent create --name "GPT Bot" --provider openai --model gpt-4o
ironctl agent create --name "Local Bot" --provider local --model llama3.2 # uses IRONCLAW_LOCAL_MODEL_URL
```
Valid `--provider` values: `anthropic` (default), `openai`, `openrouter`, `codex`, `gemini`,
`vertex`, `local` (a self-hosted OpenAI-compatible endpoint — Ollama/LM Studio/vLLM/llama.cpp), and
`mock` (a deterministic, offline backend for demos and tests). Each maps to a model-proxy-allowlisted
upstream; `codex` targets `chatgpt.com` and defaults to the `gpt-5.5` model, and `local` inherits the
loopback host from `IRONCLAW_LOCAL_MODEL_URL`.
## Configuration
- **State** lives under `--state-dir`: the durable gateway change store (survives restart), the
append-only JSONL audit log, and the host keystore.
- **Secrets** are host-only. The model credential (an Anthropic / OpenAI / OpenRouter key, or a
credential gateway like OneCLI — see [Model providers](#model-providers)) is applied to outbound
model calls by the host `modelproxy`; the sandbox never sees it and has `network=none`. Per-session 256-bit keys are
gene