Contributing to IronClaw
August 1, 2026 · View on GitHub
By contributing to this repository, you agree to grant the project maintainers a permanent, non-exclusive, worldwide, royalty-free license to use, modify, and commercially dual-license your contributions. This is formalized in the Contributor License Agreement, which the CLA Assistant bot asks you to sign on your first pull request. Sign it in one click at https://cla-assistant.io/IronSecCo/ironclaw. Posting a sign comment on the pull request does not work here, see Signing the CLA.
Thanks for your interest in IronClaw! Contributions of every kind are welcome — bug reports, fixes, new channel adapters, docs, and tests.
New here? The documentation site is the best starting point — architecture, threat model, quickstart, channels, and skills in one place.
Quickstart — your first PR in 5 minutes
From a clean checkout (Go 1.23+, with CGO_ENABLED=1 for the SQLCipher binding):
# 1. Fork on GitHub, then clone your fork
git clone https://github.com/<you>/ironclaw && cd ironclaw
# 2. Build and run the checks (this is exactly what CI runs)
export CGO_ENABLED=1
make build vet test # or: go build ./... && go vet ./... && go test ./...
# 3. Branch, make your change, and verify formatting
git checkout -b my-change
gofmt -l . # must print nothing
# 4. Commit and push to your fork, then open a PR against `main`
git commit -am "docs: fix a typo"
git push -u origin my-change
Then open the pull request, fill in the template, and link the issue it closes. A maintainer reviews and merges.
Looking for something to work on? Grab a good first issue (comment to claim it first), or ask in Discussions. The rest of this guide covers the ground rules, the frozen contract, and the code layout in detail.
Signing the CLA
One click, and it is the only setup step there is:
https://cla-assistant.io/IronSecCo/ironclaw
The signature is recorded against your GitHub account, so you sign once and every later pull request is covered. The CLA Assistant bot posts that same link on your first PR.
Do not try to sign by comment. Other projects accept a
I have read the CLA Document and I hereby sign the CLA comment on the pull request.
This one does not: the bot we run only reads pull request events, so a sign comment is
silently ignored and license/cla stays red no matter how many times you post it.
If you have signed and the check is still red, use the recheck link in the bot's
comment rather than replying to it.
Good places to start
Two subsystems are deliberately self-contained — you can make a real, mergeable change in either without loading the whole architecture in your head:
ironctl scan(internal/host/scan/,cmd/ironctl/scan.go) — grades a container's containment posture 0-100 across seven dimensions. Pure, table-testable scoring logic and a small CLI surface. Good for test coverage, new flags, and output polish. Seedocs/scan.md.- The isolation scores dataset (
examples/isolation-survey/) — the data behind the public container isolation scores directory. Adding images toimages.txtis a data-only change (no Go, no build); the weekly CI survey grades and publishes them for you.
Filter the open issues for these areas with the
area:cli,
area:sandbox,
and category:test
labels.
Ground rules
- Open an issue first for anything non-trivial, so we can agree on the approach before you invest time.
- Keep changes small, focused, and reversible — one concern per pull request.
- No secrets in code, tests, fixtures, or logs.
- Be excellent to each other — see the Code of Conduct.
Contributor License Agreement (CLA)
Before your first contribution can be merged, you'll sign our Contributor License Agreement. It confirms you have the right to contribute your work and lets IronSecCo offer IronClaw under both the open-source AGPLv3 and a commercial license (the project's dual-license model).
There's nothing to do up front: when you open your first pull request, the CLA Assistant bot comments with a link, and you sign in one click with your GitHub account. It remembers your signature for future PRs.
Where we have accepted a contribution without a captured signature, and on what
evidence, is on the public record in
CLA exceptions. Its
companion page,
Merge exceptions,
records every commit that reached main without the approving review the branch
ruleset requires. Both exist because an exception that is not written down is
indistinguishable from a process failure.
Development setup
IronClaw is Go 1.23+ and requires CGO_ENABLED=1 (the SQLCipher binding behind
the encrypted queues). Before opening a PR, make sure the standard checks pass:
export CGO_ENABLED=1
gofmt -l . # must print nothing
go vet ./...
go build ./...
go test ./...
make build vet test runs the same checks. See docs/building.md
for the full build notes.
Examples smoke gate (credential-free)
Changes under examples/** (or the demo control-plane / sandbox they exercise) trigger
the Example smoke workflow. It runs
hello-ironclaw, the red-team-escape proof, and a credential-free smoke matrix
over every examples/*/run-mock.sh recipe against the offline mock provider — no model
key, no channel tokens.
Each run-mock.sh must fail-closed on an empty assistant reply. The chat
/messages route is drain-on-read and the reply text lives in .messages[].content
(NOT .text, which is the /chat/send request field); reading the wrong key silently
drains the reply to empty. IRO-279 shipped exactly that regression — the scripts still
exited 0 while returning nothing. So when you add or edit a mock recipe, keep the
non-empty assertion (return 1 / exit 1 on an empty poll) so a wrong-field or broken
round-trip turns the check red instead of passing silently. The sandbox image and demo
control-plane are built once and shared across the matrix, so keep each recipe's own
runtime tight. The job is additive and non-gating (not a required check).
You can run the whole matrix locally:
bash container/build.sh # build ironclaw-sandbox:latest once
docker compose -f docker-compose.demo.yml up --build -d
for s in examples/*/run-mock.sh; do bash "$s" || echo "BROKE: $s"; done
docker compose -f docker-compose.demo.yml down
The frozen contract
internal/contract/** is the single seam shared by the control-plane (host) and the
sandbox — the only package both sides import. It is frozen: a drift here is a
silent decrypt failure or routing mismatch at runtime, not a build error. Every file
in it carries the banner
// FROZEN CONTRACT — do not edit without a joint RFC (see docs/contract.md).
Changing it requires:
- A joint RFC entry appended to
docs/contract.md. - Approval from the code owners (see
CODEOWNERS).
Code layout
| Area | Path |
|---|---|
| Control-plane / host | internal/host/**, cmd/controlplane, cmd/ironctl, api/, deploy/ |
| Sandbox runtime | internal/sandbox/**, cmd/sandbox |
| Shared frozen seam | internal/contract/** (see above) |
| Behavioral parity suite | test/parity/** (shared — add specs, don't rewrite others') |
Documentation
The docs site (MkDocs Material, under docs/) has no shared nav:
block in mkdocs.yml. The navigation is assembled at build time from
per-directory .nav.yml fragment files, so two docs PRs almost never touch the
same nav line and no longer conflict on merge.
To add a page:
- Drop the
.mdfile in the right directory with a top-level# H1(its title). - For the churn-heavy sections —
providers/,tutorials/,integrations/— that is all you need: the directory's.nav.ymlends with a"*.md"glob that auto-includes any new page (titled from its H1), appended after the explicitly listed ones. Add an explicit- My Label: my-page.mdline to that directory's.nav.ymlonly if you want a custom label or a specific position. - For a page in another section, add one line to the matching section in
docs/.nav.yml(the root fragment).
mkdocs build --strict must stay green (pip install -r docs/requirements.txt
then mkdocs build --strict). The assembler fails the build loudly if a
fragment points at a missing page, so a typo can never silently drop a page.
See the top of docs/hooks.py for the fragment format.
Adding a hardening guide: which indexes are exhaustive
Hardening guides (docs/blog/harden-*.md) are listed on four surfaces, and only
two of them are meant to list every guide. The split is deliberate, so knowing
which is which saves you a round of review:
| Surface | Exhaustive? | Enforced by |
|---|---|---|
docs/blog/.nav.yml — the blog nav | Yes, every guide | scripts/check-guide-index.py in CI |
docs/blog/hardening-guides.md — the hub | Yes, every guide | scripts/check-guide-index.py in CI |
docs/blog/index.md — the blog landing page | No, curated | maintainer, case by case |
README.md | No, curated | maintainer, case by case |
Adding a guide requires the first two and nothing else. Wire your new guide
into the nav and the hub, and CI is satisfied — check-guide-index.py fails the
build if either is missing, and that is the whole contract.
Do not add your guide to docs/blog/index.md or README.md, and do not treat
its absence there as a bug to fix. Neither page claims to list every guide, and
most guides are deliberately absent from both — both link the hub, which is the
exhaustive one. They are curated for different reasons:
docs/blog/index.mdgives every entry a hand-written summary quoting that guide's real before/after scores ("scores 48 of 100 (D) … the honest hardened ceiling is 89 of 100 (B)"). Making it exhaustive would mean inventing a summary and a score line per guide, and a rule that pressures an author into fabricating numbers is worse than no rule.README.mdcarries a short, representative selection of guides as a link list. It is the project's front door, not a catalog, and it stays short on purpose.
Whether a particular guide earns a spot on either is an editorial call a maintainer makes case by case, so please leave it to review rather than backfilling it in a PR.
The same reasoning, from the enforcing side, is in the module docstring of
scripts/check-guide-index.py.
Pull requests
- Branch from
main, make your change, and open a PR againstmain. - Fill in the PR template, link the issue it closes, and make sure CI is green.
- A maintainer reviews and merges. We keep
mainreleasable at all times.
Good first contributions
The fastest way in is a good first issue — these are small, self-contained, and mentored. Comment on one to claim it before you start.
Channel adapters (internal/host/channels/) are also small, uniform, and dependency-free
— a great first PR. See Writing a channel adapter
for the interface and house pattern, and docs/channels.md for how
each existing adapter is configured.
Have a question instead of a change? Open a thread in GitHub Discussions.