The premise
On Solana, the cost of a bug isn’t a 500 error. Programs hold user funds, deployments are public, and attackers read your bytecode the hour you ship it. Meanwhile, the way code gets written has changed under our feet: an agent in a terminal writes the handler, the tests, sometimes even the migration — faster than I can, and without getting tired.
The obvious responses are both wrong. “Don’t let AI touch on-chain code” throws away the biggest productivity gain of the decade. “Just review everything carefully” doesn’t survive contact with a 2,000-line diff generated in an afternoon. In my experience, the answer is to stop treating safety as a property of the author and make it a property of the system: guardrails that hold no matter who — or what — is typing.
This article is about the first of those guardrails. It looks like DevOps housekeeping. It’s actually the load-bearing wall.
The companion repository is built on Pinocchio, Anza’s zero-dependency framework for SVM programs. Where Anchor wraps your handler in macro-generated account validation, Pinocchio gives you an entrypoint, an `AccountView`, and gets out of the way:
#[cfg(feature = "bpf-entrypoint")]
pinocchio::entrypoint!(process_instruction);
You parse your own accounts. You verify ownership. You calculate rent. In return, you get tiny binaries, low compute-unit costs, and—most importantly—nothing happens that you didn’t write down. There is no macro layer where subtle behavior can hide.
That’s exactly what you want when an AI is writing the code—and exactly what makes it unforgiving. Every safety check the framework used to perform is now a line that someone has to remember to write.
Parts 2 and 3 show how to make “remembering” unnecessary. Part 1 is about making sure the rules are enforced identically for everyone.
The entire repository is operated through`just`. Not make, not a pile of shell scripts, not “see CONTRIBUTING for the cargo incantation”—just one discoverable command map:
just rust fmt-check / check / clippy / test
just solana build / test-it / test-client-rust / gen-idl / gen-client
This matters for humans, but it matters even more for an agent. An agent’s effectiveness is bounded by the quality of its feedback loop. If “run the tests” means a different command on your machine, in CI, and in the README, the agent will eventually run the wrong one, trust a green result that checked nothing, and build on sand. In this repository, there is exactly one way to do anything, it’s listed by `just –list`, and the agent contract (more on that below) points to it.
The recipes themselves are deliberately thin. Here is the entire Rust check lane:
# Run cargo check for the whole workspace
./scripts/docker/run-tooling.sh {{ rust_tooling_image }} cargo check --workspace
A recipe declares which tooling image it needs, then shells into a wrapper. Which brings us to the actual trick.
The host machine needs Docker and `just`. That’s the complete prerequisite list: no rustup, no Solana CLI, no Node, no provers. Every command runs inside one of four tooling images, built from a single `Dockerfile` on top of a shared `tooling-base` stage (Debian + build essentials + [`mise`](), the version manager that pins everything else):
– `rust-tooling` — pinned stable Rust, a pinned nightly (rustfmt only), nextest, cargo-machete;
– `node-tooling` — pinned node + pnpm, used only for the Codama client codegen;
– `solana-tooling` — the Rust image plus a pinned Agave release with `cargo-build-sbf`;
– `creusot-tooling` — the formal-verification stack (part 3): a dedicated nightly, Why3, and SMT solvers.
Versions live in committed `mise.toml` files, one per concern:
rust = { version = "1.96.0", profile = "minimal", components = "clippy" }
"ubi:bnjbvr/cargo-machete" = "0.9.2"
"aqua:nextest-rs/nextest/cargo-nextest" = "0.9.136"
Overlays handle the scoped deSE_ENV=solana` the Agave toolchain, `MISE_ENV=creusot` the verifier’s own nightly:
# Creusot's pinned nightly, selected with MISE_ENV=creusot. Creusot is a custom rustc driver, so it
# needs rustc-dev + llvm-tools; mirror Creusot's own rust-toolchain when upgrading.
rust = { version = "nightly-2026-04-21", profile = "minimal", components = "rustc-dev,llvm-tools,rustfmt" }
One line in the Dockerfile does more for agent safety than any prompt I’ve written:
Either the pinned tool runs, or the command fails loudly. An agent cannot drift the toolchain
mid-session, cannot “helpfully” install a newer compiler, cannot produce a result that depends on what happened to be in `$PATH`. When a model hallucinates a flag that doesn’t exist in your pinned version, you want the error now, not in CI three hours later — and definitely not never.
Eighty lines that make everything boring
The wrapper, `scripts/docker/run-tooling.sh`, is the only place where Docker is touched, and its core is small enough to read in one sitting:
--platform "${image_platform}"
--volume "${repo_root}:/workspace"
--env MISE_TRUSTED_CONFIG_PATHS=/workspace
--env "XDG_CACHE_HOME=${xdg_cache_home}"
--env "XDG_CONFIG_HOME=${xdg_config_home}"
--env "XDG_DATA_HOME=${xdg_data_home}"
The details that took iterations to get right:
– Your UID, not root.The repo is bind-mounted, and the container runs as $(id -u):$(id -g), so build artifacts on the host are never left behind as root-owned debris.
–Caches live in the repo. Cargo’s registry, mise’s state, and every XDG path point into a
gitignored `.cache/` inside the workspace — warm builds across runs, zero state outside the project directory. One subtle bug worth passing on: export *container* paths (`/workspace/.cache/…`) in those env vars, not host paths. The host path doesn’t exist inside the container, and every tool that tries to write state will warn — or silently lose its cache — on every single run.
– `mise exec` as the launcher. The image’s entry is `mise exec — <your command>`, which injects exactly the pinned tools into `PATH`. No login shells (a login shell
The payoff: `git clone`, any `just` recipe, and you get the exact same pipeline the agent used while writing the code—and the same one the pre-push hook replays before anything leaves the machine. “Works on my machine” isn’t a sentence that can be formed in this repository.
A deterministic environment makes the agent’s actions safe. Two small files and one vendored directory make them competent.
First, the contract. `CLAUDE.md` is three lines that redirect to `AGENTS.md`, and `AGENTS.md` is the repository’s interface for any coding agent: the layout, ther non-negotiable — the handoff minimums:
- Before finishing Rust changes, run `just rust fmt-check` and `just rust check`
- For behavior changes, also run `just rust test`
- For changes touching the program or Mollusk integration tests, also run `just solana test-it`
- For changes touching Creusot specs (`domain/`, `flows/`) or `verif/`, run `just creusot replay`
(regenerate sessions with `just creusot verify` when specs changed)
- Prefer `just rust clippy` before larger submissions
This is the definition of “done” that the agent reads before it starts. It’s not a suggestion in a chat window that evaporates with the session; it’s versioned, reviewed, and applies to every session and every agent equally.
Second, the domain knowledge. Generic models know Solana from training data of mixed vintage — which is how you get `solana_program 1.x` idioms pasted into a Pinocchio codebase. The fix is the [`solana-dev`skill] maintained by the Solana Foundation: a curated, current playbook (account model, PDAs, CPIs, Codama, Mollusk/LiteSVM testing, security checklists) that the agent loads on demand. I vendor it into the repo with the `skills` CLI rather than letting the agent fetch knowledge ad hoc.
"skillPath": "skill/SKILL.md",
"computedHash": "27419a2b51070cdd9c3653c8ba3789ab12cdc09cce1383c4fdedc979cf7d15b6"
That `computedHash` is the point. The skill is pinned and content-addressed like any other dependency: `npx skills add solana-foundation/solana-dev-skill` to vendor, `just update-skills` to bump deliberately, and a diff to review when it changes. The agent’s knowledge of your stack goes through code review like everything else.
Guardrails that don’t trust anyone
The last piece assumes everyone — including me — will eventually try to push garbage. A repository-tracked pre-push hook (`just init-hooks` wires it up once) diffs the outgoing range and runs only the lanes affected by the change:
has '^(crates/|clients/|idl/|Cargo.toml|Cargo.lock|rustfmt.toml|clippy.toml|mise(.[a-z]+)?.toml|Dockerfile)' && rust_changed=true
has '^(crates/ticketing/|mise.creusot.toml|just/creusot.just|why3find.json|verif/|Dockerfile)' && creusot_changed=true
Rust changes get formatting, Clippy, the test suite, the on-SVM integration tests, and a dead-dependency sweep. Changes to the verified core also trigger a proof replay (Part 3). The hook calls the same just recipes as everything else, so there’s nothing special to maintain—and nothing an agent can bypass by accident, because the agent’s turn ends before any push happens. Pushing is mine.
None of this is exotic. That’s the point. Hermetic, pinned, single-entry tooling used to be a nice-to-have that mature teams eventually got around to. With an AI in the loop, it’s the difference between an agent that converges—same input, same failure, same fix—and one that flails against a moving target.
The setup takes an afternoon. The images build on demand (the Rust image in a couple of minutes; the prover image in Part 3 is the expensive one, taking about half an hour the first time). After that, everyone involved in the project — you, the agent, CI, or a contributor who cloned the repository five minutes ago — speaks the same eleven commands.
With those guardrails in place, the next question is what the agent is allowed to build on top of them. Part 2 moves down to the data layer: why I don’t deserialize anything on-chain, how bytemuck::CheckedBitPattern turns parsing into validation, and the layout contracts that make it impossible for a refactor — human or AI — to silently corrupt an account.
The companion repository — a complete, formally verified event-ticketing program — is at https://github.com/kalaninja/pinocchio-workshop
Part 2: The Interface. Part 3: The Program.

