Handrail documentation

Handrail installs policy packs for coding agents where the agent cannot change them. This version supports Claude Code on macOS and Linux.

Install

One command:

curl -fsSL https://handrail.bitey.ai/install.sh | sh

The installer detects your OS and architecture, downloads the matching binary from the GitHub release, checks its SHA-256 against the release's SHA256SUMS, and installs it to /usr/local/bin. Only that final copy uses sudo. It does not change any agent configuration.

VariableEffect
HANDRAIL_VERSION=v0.1.0Install a specific release instead of the latest.
HANDRAIL_INSTALL_DIR=…Install somewhere else. See the note below.
HANDRAIL_VERIFY_PROVENANCE=1Also require gh attestation verify to confirm the binary was built by this repository's release workflow.

Keep the binary where only root can write. Handrail runs itself through sudo to change enforced policy. A binary your user can overwrite could be replaced by anything running as you before sudo runs it. handrail doctor checks this.

Read the installer first

curl -fsSLO https://handrail.bitey.ai/install.sh
less install.sh
sh install.sh

Verify provenance

Release archives carry a signed build-provenance attestation. With the GitHub CLI:

gh attestation verify handrail-aarch64-apple-darwin.tar.gz --repo martinx/handrail

Other ways to install

Instead of the script, you can use one of:

WithCommand
Homebrewbrew install martinx/tap/handrail
Cargocargo install handrail

Both install into a directory your user can write (the Homebrew prefix, ~/.cargo/bin). For enforced policy, the install script above is the safer choice.

Update

handrail self-update --check     # is there a newer release?
handrail self-update             # update

Script installs are updated with the same checksum-verified installer, pinned to the version just reported. Homebrew and cargo installs are pointed at brew upgrade handrail and cargo install handrail: package managers own their files.

Be told about new releases

handrail self-update --auto on    # off by default

With the daily check on, handrail statusline shows ↑0.1.2 when a newer release exists. It never installs anything — updating stays one command you run. The status line never waits on the network: it reads a cached answer, and refreshes it in the background at most once a day with a single request to api.github.com. Turn it off with --auto off. With it off, Handrail uses the network only when you run self-update.

Why no automatic install: Handrail runs with administrator rights, so an automatic update would turn a compromised release channel into code running as root on every machine. Homebrew also requires software not to update itself.

From source

git clone https://github.com/martinx/handrail && cd handrail
cargo build --release
sudo install -m 755 target/release/handrail /usr/local/bin/handrail

Quick start

handrail list                       # packs, and how strongly Claude Code enforces each
handrail show privacy               # what a pack protects, its tradeoffs, its limits
handrail use baseline --dry-run     # the exact files that would change
handrail use baseline               # apply: confirm, then your password once

Start a new claude session afterwards; running sessions keep their old configuration.

Commands

CommandWhat it does
list [--category c]Packs by category with tier and enforcement level. * marks installed packs.
show <pack>Everything about a pack: what it protects, tradeoffs, limits, per-agent enforcement, profiles it belongs to.
profilesNamed sets of packs.
use <profile>Make the installed packs exactly this profile. Your own rules are kept.
enable <pack…>Add packs to what is installed.
disable <pack…>Remove packs. --all removes every pack and rule and leaves nothing behind.
rule add|list|edit|removeYour own rules, added to the enforced instructions block. Each has an id (rule-7k3m, or your own with --id) to edit or remove it by.
rollbackUndo the last change.
statusWhat is installed, and anything that would make it ineffective.
statuslineOne line for Claude Code's status line: the matching profile or pack count, and drift when installed files differ from what Handrail wrote. Local reads only.
self-update [--check] [--auto on|off]Update Handrail itself to the latest release; --auto on enables a daily check shown in the status line (nothing is installed automatically).
check [dir]Validate a catalog directory: pack format, hook test vectors, script lint. For pack authors and CI.
publish <pack> --catalog <dir>Propose your pack to the shared catalog as a pull request. --repo owner/name targets another catalog repository. See Share and publish.
doctorChecks: Claude Code present, binary not user-writable, no overriding policy sources, no interrupted runs, no tampering.

Every command also accepts --catalog <dir|repo> (repeatable), which adds the packs in a directory or git repository to the built-in ones. See Share and publish.

Every command that changes something accepts --dry-run (show the plan, write nothing) and --yes (skip the confirmation prompt; the password prompt remains).

Two tiers

The agent runs as you. Anything you can change without administrator rights, it can change too — including turning a rule off. So packs come in two tiers:

EnforcedAdvisory
ForSecurity and privacy guaranteesPreferences and conventions
LocationClaude Code's managed policy directory~/.claude/rules/
Ownerrootyou
Changing itNeeds your passwordNo password
Agent can undo itNoYes, by design

Separately, each pack states how strongly each agent enforces it:

LevelMeaning
enforcedApplied by the agent's own settings, permission rules, hooks or sandbox, whatever the model does.
partialEnforced for the common paths; the pack's limits say what gets through (for example, a script that opens a file itself).
advisoryInstructions only. The model is asked, not forced.

Where files go

The managed policy directory is /Library/Application Support/ClaudeCode/ on macOS and /etc/claude-code/ on Linux. Settings there take precedence over user, project and command-line settings, and the CLAUDE.md there is loaded in every session and cannot be excluded.

PathContents
managed-settings.d/handrail-<pack>.jsonOne fragment per pack. Claude Code merges fragments in name order: lists are combined, objects merge by key. Handrail's test suite rejects two packs that set the same scalar key differently, because the later one would silently win.
handrail/packs/<pack>/hooks/Hook scripts referenced by that pack's settings.
CLAUDE.mdA block between <!-- handrail:begin --> and <!-- handrail:end -->. Everything else in the file is left alone, and removing the block restores the original bytes.
handrail/state.jsonWhat is installed, and exactly which files Handrail owns.
handrail/backups/The five most recent backups, used by rollback.

Handrail never modifies files it did not create, including managed-settings.json.

How changes are made

  1. Plan. The change is computed as an exact list of file writes and deletions, each with the file's current hash as a precondition. --dry-run prints it.
  2. Elevate the intent, not the files. For the enforced tier, only which packs and rules you want is passed to sudo handrail __apply. The privileged process recomputes the plan from the catalog compiled into the binary and refuses unless its hash matches the plan you reviewed.
  3. Apply transactionally. Check every precondition, validate every JSON file (an invalid managed settings file stops Claude Code from starting), stage new content, back up what will be replaced, write a journal, then commit. If the process dies during the commit, the next run restores the previous state from the journal.

Because sudo needs a terminal to ask for your password, an agent running commands without one cannot raise its own privileges to change enforced policy.

Verify it's working

In Claude Code's status line

Claude Code runs a command of your choice to draw its status line (statusLine in ~/.claude/settings.json). Add Handrail's segment to it:

"statusLine": { "type": "command", "command": "handrail statusline" }

If you already have a status line script, append $(handrail statusline) to its output. It prints e.g. handrail: baseline ✓, or drift ⚠ when installed files were changed.

Limits

Your own rules

handrail rule add "Reply in English"                 # gets an id such as rule-7k3m
handrail rule add "Small, focused commits" --id commits
handrail rule list                                   # id and text
handrail rule edit commits "One logical change per commit"
handrail rule remove rule-7k3m commits

Generated ids are rule- plus four characters from an alphabet without look-alikes (no 0 1 i l o). They are random, not derived from the text, so editing a rule keeps its id. Your own ids use lowercase letters, digits and hyphens, up to 32 characters.

Rules land in the root-owned instructions block, loaded in every session. They are instructions to the model, not enforcement.

Writing a pack

Write a pack in any directory; a catalog is just a folder with packs/ in it (and optionally profiles/). The complete example on the home page is a working pack you can copy. Each pack is a directory:

my-packs/packs/my-pack/
├─ pack.toml                   # metadata, tier, per-agent spec
├─ rules.md                    # instructions for the agent (English)
├─ claude-code/
│  ├─ settings.json            # merged as a managed-settings.d fragment
│  └─ hooks/guard.sh           # optional
└─ tests/guard.cases           # hook test vectors
id = "my-pack"
version = "1.0.0"
category = "security"
tier = "enforced"            # or "advisory": rules.md only
title = "Short title"
summary = "One or two sentences."
protects = ["What it guards against"]
tradeoffs = ["What the user gives up"]
limits = "What it cannot do. Required."

[targets.claude-code]
enforcement = "enforced"     # enforced | partial | advisory | unsupported
min_version = "2.1.242"      # optional: oldest Claude Code with the keys you use
settings = "claude-code/settings.json"
hooks = ["claude-code/hooks/guard.sh"]

In settings.json, @PACK_DIR@ is replaced with the directory the pack is installed to, so a hook is referenced as "\"@PACK_DIR@/hooks/guard.sh\"". Test vectors are tab-separated lines: name, expected exit code, JSON input.

handrail check . validates every pack, locally and in that repository's CI: required fields, unknown fields and placeholders, settings that are valid JSON objects, scalar keys that clash with another pack, and each hook's test vectors. Only claim enforced for what the agent actually enforces, and keep limits honest — a pack that claims more than it enforces is a bug.

Share and publish

Try a pack from your directory with --catalog; nothing is written until you confirm the plan:

handrail check ./my-packs
handrail enable my-pack --catalog ./my-packs --dry-run
handrail enable my-pack --catalog ./my-packs

Handrail installs a copy of an external pack next to the files it writes (in the root-owned directory for enforced packs), so status, disable and later changes keep working without the source, and the privileged step reads the root-owned copy. To install a new version, run enable with --catalog again. list and show say where an external pack came from. An external pack cannot reuse a built-in pack's id or set a setting a built-in pack already sets to a different value.

Share with a link

Push the directory to any git repository; others install from it directly, with no review step:

handrail enable my-pack --catalog github.com/you/my-packs
handrail enable my-pack --catalog git@github.com:your-org/policy-packs.git

Only install packs from people you trust: an enforced pack's hooks run on every matching tool call.

Publish to the shared catalog

handrail publish my-pack --catalog ./my-packs --dry-run
handrail publish my-pack --catalog ./my-packs

publish runs handrail check, then opens a pull request on martinx/handrail-packs through the GitHub API: it forks the repository into your account when you can't push to it, commits the pack on a branch pack/<id>-<version>, and opens the pull request. Publishing the same version again updates that pull request; files removed from the pack are removed in it too.

Roadmap

NextWhat
RegistrySigned, versioned packs fetched with handrail update: shows a diff, never auto-applies executable content.
Terminal UIBrowse and toggle packs by category, with the enforcement matrix.
More categoriesCommunication, git hygiene, code quality, testing, cost.
More agentsChosen by demand, each with a published enforcement matrix.

Uninstall

handrail disable --all                 # remove every pack and rule
sudo rm /usr/local/bin/handrail        # remove the binary

A backup of the removed files is kept in your temporary directory until reboot.

FAQ

Is this made by Anthropic?

No. Handrail is an unofficial, independent project. It only uses configuration that Claude Code documents.

Does Handrail send anything anywhere?

No. It has no telemetry and no account. The CLI works offline and only goes online when you ask it to: self-update, --catalog with a repository, and publish. The opt-in daily update check reads the latest release number from GitHub.

Where do pack updates come from?

The built-in packs are compiled into the binary, so an install always has the packs of that release. Packs are maintained in handrail-packs; changes there are synced into Handrail daily and shipped with the next release. Get them with handrail self-update (or brew upgrade handrail), then run handrail status: installed packs older than the binary's are reported, and re-running handrail use <profile> or enable updates them.

Why does it need my password?

Because the point is that the agent — running as you — cannot change the policy. The same property means changing it needs administrator rights.

What if something goes wrong?

handrail rollback undoes the last change; handrail disable --all removes everything. Interrupted runs are rolled back automatically.