Contributing
Adding a rule to an existing gate is usually a single TOML edit. Adding a new gate also needs a small Rust file. The build pipeline picks up TOML changes automatically; the generator in build.rs emits the Rust gate function from the TOML on every build.
Adding a tool to an existing gate
Edit the TOML. Done.
Simplest case: add shellcheck to devtools.toml as always-safe.
# rules/devtools.toml [[programs]] name = "shellcheck" unknown_action = "allow" # always safe (read-only)
Then cargo build --release. Done. Flag-conditional behavior:
# rules/devtools.toml [[programs]] name = "prettier" unknown_action = "allow" [[programs.ask]] reason = "Writing formatted files" if_flags_any = ["--write", "-w"]
TOML schema
Root-level fields.
| Field | Description |
|---|---|
meta | Rule metadata (priority, lede, behavior tags, custom card titles). |
[[programs]] | An array of program-specific rules. |
safe_commands | An array of always-allowed command names (read-only command list). |
[[conditional_allow]] | Conditional rules that allow or gate a program based on flag presence. |
[[custom_handlers]] | Declares Rust function overrides for complex CLI logic. |
[[command_groups]] | Docs-only command groupings for Basics gate grid categorization. |
TOML schema
Program-level fields (under [[programs]]).
| Field | Description |
|---|---|
name | Program binary name as it appears on PATH. |
aliases | Alternative names. Example: ["podman"] aliased to docker. |
unknown_action | What to do for unrecognised subcommands. One of allow, ask, skip, block. |
default_allow | Boolean indicating if the command is allowed by default. |
reason | Docs-only reason for unknown_action = "allow" programs. |
[[programs.allow]] | Rules that allow execution. Conditions are optional. |
[[programs.ask]] | Rules that ask the user for approval. reason is required. |
[[programs.block]] | Rules that block execution. reason is required. |
[[programs.allow_if_flags]] | List of flags that automatically allow execution. |
[programs.api_rules] | Custom HTTP API endpoint routing rules (e.g. safe methods, endpoint prefixes). |
Rule conditions
How rules match (under allow/ask/block rules).
| Field | Description |
|---|---|
subcommand | Match a specific subcommand string. Example: "pr list". |
subcommands | Match any of the listed subcommands. |
subcommand_prefix | Match a subcommand prefix. Example: "describe" matches describe-instances, describe-volumes, etc. |
action_prefix | Match a prefix on args[1]. Used for AWS-style describe-* / list-* / create-* patterns. |
if_flags_any | Apply the rule only if any listed flag is present. |
unless_flags | Apply the rule unless any listed flag is present. |
if_args_contain | Apply the rule if args contain any listed string. Used for path-aware blocks. |
unless_args_contain | Apply the rule unless args contain any listed string. |
warn | On [[programs.ask]]: mark as dangerous-but-recoverable. Surfaces on the Security floor page. |
accept_edits_auto_allow | On [[programs.ask]]: auto-allow this program when the session is in acceptEdits mode. |
Custom handlers
When TOML can't express it.
Some logic doesn't fit declarative TOML: path normalisation (rm against / or ~/), method-aware HTTP routing (gh api), shell-script inner-command checking (bash -c). For those, write a Rust handler.
# rules/devtools.toml [[custom_handlers]] program = "ruff" handler = "check_ruff" description = "ruff format asks unless --check or --diff present"
Then implement check_ruff in the gate file. The generated gate returns Skip for this program, letting the custom handler take over.
Custom handlers return one of the same four gate results used by the generated functions:
GateResult::skip() // this gate has no opinion GateResult::allow() // explicitly safe GateResult::ask("Description") // mutation or unresolved risk GateResult::block("Explanation") // deterministic safety floor
Adding a new gate
Three steps.
For a new category of tools that doesn't fit an existing gate.
1. Create rules/newgate.toml. Pick a priority that slots in correctly; lower runs first, basics at 100 is always last.
[meta] name = "newgate" description = "New category of tools" priority = 50 [[programs]] name = "newtool" unknown_action = "ask" [[programs.allow]] subcommand = "list" [[programs.ask]] subcommand = "create" reason = "Creating resource"
2. Create src/gates/newgate.rs. Thin wrapper around the generated function.
use crate::generated::rules::check_newgate_gate; use crate::models::{CommandInfo, GateResult}; pub fn check_newgate(cmd: &CommandInfo) -> GateResult { check_newgate_gate(cmd) }
3. Register in src/gates/mod.rs. Add to the GATES array in priority order.
mod newgate; pub use newgate::check_newgate; pub static GATES: &[(&str, GateCheckFn)] = &[ // … other gates … ("newgate", check_newgate), ("basics", check_basics), // basics last ];
Simulator
WebAssembly compilation.
The Try page runs a local simulator powered by a WebAssembly build of the gate engine. The compilation uses the release-wasm profile, the wasm32-unknown-unknown target, and compiles only the library target with the wasm feature enabled.
1. Build via mise. The project uses a mise task to build the WebAssembly module. It compiles the Rust codebase and uses Zig CC via a wrapper script to compile the C-based tree-sitter parser without requiring a manual clang/WASI-sdk installation. Run the following command:
$ mise run build-wasm
This compiles the target, runs wasm-bindgen to generate bindings in docs/src/wasm/, and runs wasm-opt with the bulk memory feature enabled to optimize the output size.
Testing
Test rules.
| Rule | Why |
|---|---|
| No real-world data in tests | Generic placeholders only (mytool, $HOME/scripts/deploy/, my-service). Tests are committed to a public repo; never leak usage patterns or personal workflows. |
| CI portability | Don't assume specific modern CLI tools (rg, bat, fd) are installed. CI runners have minimal environments. Detect tool availability at runtime and skip gracefully. |
| Serde output verification | Any struct serialized to JSON for Claude Code needs a test asserting exact field casing. The CLI expects camelCase. Use serde_json::to_string and assert key names. |
Read the Reason style guide before writing new reason strings. The 250-char limit, no-em-dashes rule, and authorization-hedge ban are enforced by build.rs and by code review.