A local credential broker for AI coding agents. rein runs your agent inside
a sandbox with no direct network access and injects short-lived, repo-scoped
GitHub tokens on the wire, outside the sandbox — so the agent can clone,
fetch, push, and use gh within its session's scope, but never holds a
credential it can read or exfiltrate, and can't reach your own gh login or
SSH keys either. You create a GitHub App once via a guided browser flow; from
then on rein mints a fresh token per operation and asks you to confirm writes.
For the full design and threat model, see docs/design.md and
docs/phase1-design.md.
A real claude session under rein run: the sandboxed agent holds no
credential, and its push stays locked until you approve the issue it declared —
recorded live, and reproducible (demo/).
Status (2026-07-06). Phase 1 sandboxed mode is built and is the default:
rein runlaunches the agent inside Anthropic'ssandbox-runtime(srt) and injects credentials at a local proxy. A real agent (claude) runs end-to-end in the sandbox. It is Linux-only for now (macOS is a separate, not-yet-done track — see design §5.4). A credential-helper "direct" mode remains behind--directas a fallback where there's no sandbox. Use throwaway repos only until the sandbox has been dogfooded — see Known limits.
Core:
- Go — the version in
go.mod(currently 1.26+). - A GitHub account that can create GitHub Apps (any personal account can).
- One or more throwaway repositories to point the agent at. Do not use a
real repo yet. Clone them over HTTPS (
https://github.com/...) — rein brokershttps://github.comremotes; an SSH remote bypasses the proxy and is blocked inside the sandbox (no token, no egress). - A browser to complete the one-time App creation. On a headless/SSH box, see Headless setup.
The sandbox stack (Linux) — required for the default rein run; rein doctor checks all of these and tells you exactly what's missing:
srt— pin@anthropic-ai/[email protected](rein re-verifies on bump; other versions may move the injection hook). Needs Node 20+:npm install -g @anthropic-ai/[email protected]bubblewrap,ripgrep,socat—sudo apt-get install -y bubblewrap ripgrep socat(or your distro's equivalent). Theapply-seccomphelper that blocks the agent from reaching keyring/agent sockets ships withsrt.- Ubuntu 24.04+ only: an AppArmor profile granting
usernstobwrap, or the sandbox won't start. Check withbwrap --unshare-user --uid 0 --bind / / -- true; if it errors, see the fix inHANDOFF.md(§1b). - Healthy NTP — GitHub App token mints fail with a misleading
401 Bad credentialswhen the clock drifts >~60s. Keep time sync on (chronyc trackingshould show ~0 seconds off).
git clone https://github.com/TomHennen/rein.git
cd rein
go build -o bin/ ./...
# install the sandbox stack (see Prerequisites), then:
./bin/rein init
./bin/rein doctor # every check should be [ok] — including the sandbox: rowsrein init is idempotent — safe to re-run.
rein init walks you through everything. On a fresh machine it will:
- Create your GitHub App(s). A browser opens to GitHub's "Create GitHub
App" page with the right permissions pre-filled. Click Create, then
Install it on your throwaway repo(s). rein creates a primary App
(mints your tokens) and — unless you pass
--skip-audit— an audit App (reserved for audit-comment writeback, a later track; created now but not yet posting), each in its own browser step. Use--owner=<your-login>so rein refuses if you accidentally create the App under the wrong account. - Store the keys. The private keys are written to
~/.config/rein/{primary,audit}.pem(mode0600) and the App details to~/.config/rein/state.json. You never copy a key by hand. rein's local CA (used to inject on the wire) is generated on firstrein runand stored the same way. NoREIN_APP_*environment variables are needed — rein readsstate.jsonand fetches the installation id automatically on first use. - Wire up your shell. rein installs its git/
ghshims and putsreinon yourPATH(~/.local/bin/rein). Thealias claude='rein run -- claude'convenience is opt-in and is not installed by default. On a real terminal init asks "Add alias claude='rein run -- claude' to ? [y/N]" (defaulting to No); pass--aliasto install it non-interactively, or--no-aliasto force-skip. If you pass both,--no-aliaswins. Headless/CI runs and--yesnever prompt and skip the alias. Without the alias, launch withrein run -- claude; you can enable it later withrein init --alias. - Scaffold your dev session. init writes
~/.config/rein/dev-session.yamlscoped to a repo you name. It takes the repo from--repo owner/name, else it prompts "Which repo should the agent work on?" (Enter to skip). On a headless/CI run or with--yes, and no--repogiven, it skips scaffolding gracefully — init never blocks on a prompt. Re-running init keeps an existing session file.
Sandbox-stack health. At the end of its run, init checks the sandbox stack
(the same sandbox: ... rows rein doctor reports). If anything is unhealthy it
soft-blocks: init still finishes all its other setup, then prints a loud,
specific warning naming each failing check and pointing you at rein doctor and
the Prerequisites — but exits 0. Pass --require-sandbox to
make it hard-fail (non-zero exit) instead, e.g. in CI. Either way the real
enforcement is at rein run, which fails closed and refuses to launch a
sandboxed run on a broken stack.
After init, install the App on the repos you want using the deep-links rein
prints (https://github.com/apps/<slug>/installations/new).
A session sets the scope ceiling — which repos the agent may touch. rein run will not start without one, and rein init scaffolds it for you (step 4
above). You only hand-edit ~/.config/rein/dev-session.yaml to change the repo
set or to enable writes. What init writes is repo-only:
id: my-session
role: implement
repos:
- your-name/your-throwaway-repo # the token is scoped to this whole setWhy no issue field? The issue is bound at runtime, not at setup
(#35): the agent declares
which issue its work is for (rein declare <n>), rein fetches that issue and
shows you its title, state, and home repo on your terminal, and you confirm
by typing the displayed number. That one ceremony unlocks writes for the run
(git push, gh, API); every push is still verified against the
agent/<issue>/<nonce> branch convention in sandboxed mode. A session file
with a legacy issue: line still loads, but the field is ignored (with a
loud warning) — remove it.
Direct mode (--direct) deltas, stated up front: the same declare +
confirm model applies, but (1) the credential helper never sees push refs, so
the agent/<issue>/<nonce> cross-check is a sandboxed-only property — an
approved direct-mode run can push any ref; and (2) pre-declaration write
attempts do reach GitHub carrying a placeholder credential (GitHub rejects
them) rather than being answered locally.
Open a new shell (so the alias is live) and just run your agent:
claudeThe alias routes it through rein run, which sandboxes by default. Inside
that sandbox:
- The agent has no direct network egress — all GitHub traffic goes through rein's proxy, which injects a fresh, repo-scoped token on the wire. The token is never in the agent's environment, files, or memory.
- Your own credentials are hidden:
~/.config/gh,~/.ssh,~/.netrc, git-credentials, and the keyring/ssh-agent sockets are unreadable in the sandbox. The environment is a strict allowlist, not a passthrough. - Writes are locked until the agent declares its issue (
rein declare <n>, #35). The declaration triggers the confirmation prompt in your terminal (the agent cannot reach or forge it — it has no controlling terminal), showing the fetched issue title + home repo. One confirmation covers the run; pushes must useagent/<issue>/<nonce>branches and are verified against what you confirmed. The session also expires on idle (30m) or a hard cap (4h), revoking the write token. - Commits the agent makes are authored as
<your name> (via rein)with the App's identity, so a push is attributable to the rein App, not to you personally. (Configurable viaREIN_GIT_AUTHOR_TEMPLATE.) - The agent gets a private writable temp dir automatically — rein creates a
per-run scratch directory and wires it in as the sandbox's
TMPDIR, so tools that need scratch space (the agent itself,npm, builds) work without EROFS. It is ephemeral (torn down on exit), holds nothing sensitive, and needs no configuration from you.
rein run -- claude runs a real agent end-to-end in the sandbox — it starts,
reaches its own API, reads/writes the working tree, and pushes through the
approval prompt.
To run the agent without the alias for one invocation: \claude (bash/zsh) or
command claude (fish).
By default the sandbox blocks all network egress except two things: GitHub
(brokered through rein's proxy) and the wrapped agent's own API
(api.anthropic.com for claude, allowed automatically so rein run -- claude
works out of the box). Anything else — the npm registry, PyPI, a remote MCP
server — is unreachable until you allow its host explicitly.
Add hosts to the allow_domains allowlist, either per session or
machine-wide:
# in your session yaml — allow just this run's extra egress
allow_domains:
- registry.npmjs.org
- pypi.org# or machine-wide, for every sandboxed run (comma-separated)
export REIN_ALLOW_DOMAINS="registry.npmjs.org,pypi.org"Allowed hosts get a direct TLS tunnel to themselves — rein injects no
credential on them (they are egress-only; only GitHub gets an injected token).
Entries are bare hosts (pypi.org) or a strict wildcard (*.example.com).
Because a sandboxed agent can send data to any allowed host, widening egress is
a data-exfiltration surface: rein prints a loud EGRESS WARNING for each
wildcard and for a large custom set. Keep the list minimal and deliberate.
Claude Code's MCP servers work under rein run, with one rule: an MCP server's
network host must be reachable, i.e. in allow_domains.
- Local / stdio MCP servers (added with
claude mcp add, a project.mcp.json, ormcpServersin settings) run as a subprocess inside the sandbox and need no egress — they work out of the box. (Verified: a local stdio MCP tool is callable in-sandbox.) - Remote MCP servers, and the account / claude.ai connectors (Todoist, Gmail,
Google Drive/Calendar synced from your Claude account) reach network hosts,
so they connect only when those hosts are in
allow_domains. rein no longer force-disables these connectors; claude connects them non-blocking, so an unreachable one fails quietly in the background rather than hanging startup (tested: with connectors enabled and their hosts unallowed, the agent starts and answers normally). Note the account connectors typically also needclaude.aiegress to authenticate/fetch, on top of the third-party host (e.g. Todoist) — allow both, or expect them to stay unconnected. To turn the account connectors off entirely (minimal egress surface), setREIN_DISABLE_CLAUDE_MCP=1.
To use an MCP server in the sandbox:
- Configure it the way you normally would for Claude Code (
claude mcp add …, a project.mcp.json, or settingsmcpServers). - If it is remote, add its host to
allow_domains(session field orREIN_ALLOW_DOMAINS). Local stdio servers skip this step. - Run
rein run -- claude. The server loads inside the sandbox; a remote one connects only if its host is allowed (egress only — never an injected token).
--direct (fallback, throwaway only). Where there's no working sandbox,
rein run --direct -- <cmd> runs the credential-helper path instead — the agent
runs unsandboxed and can reach ambient credentials, so it's weaker by design.
rein prints a loud banner; use it only on throwaways. If the sandbox stack is
unhealthy, the default rein run fails closed and points you at rein doctor rather than silently dropping protection.
The App-creation step needs a browser that can reach rein's loopback callback.
On a headless or SSH-only box, rein detects this and prints a ready-to-paste
ssh -L recipe. For a predictable port, pin it:
# on the remote box:
rein init --port 41234
# on your laptop (the recipe rein prints):
ssh -L 41234:127.0.0.1:41234 you@remote
# then open the printed http://127.0.0.1:41234/ in your laptop browserThis keeps the automated, safe key import end-to-end. If port-forwarding is
blocked entirely, see the manual fallback in
docs/init-manifest-design.md (and its
Safe handling of the App private key section — read it before moving a key
by hand).
Start with rein doctor — it runs read-only checks (rein on PATH, shim
freshness, key readable, App credentials, session, the sandbox stack — srt
present + pinned version, seccomp, bwrap userns — $TMUX, caches) and tells you
what's wrong.
sandbox: ...check fails — install the missing piece from Prerequisites; on Ubuntu 24.04 the usual culprit is thebwrapAppArmor profile. The defaultrein runwon't launch until these pass.app credentials: 401— almost always clock skew; checkchronyc tracking.claudedoesn't go through rein — open a new shell, orsourceyour rc; confirm the alias withtype claude.rein: command not found(in a fresh shell, via the alias) —~/.local/binisn't on your$PATH. Add it:export PATH="$HOME/.local/bin:$PATH".- A git op fails,
rein doctormentioned on stderr — rein refused rather than letting the agent silently re-auth. Usually the App isn't installed on that repo, or the repo is outside your session's scope ceiling. - Write prompt never appears — prompts fire at declare time now (#35):
the agent must run
rein declare <n>first (every blocked write says so). If the declare ran but no prompt reached you, you may be using--directfrom a shell with no tty; run from a real terminal, or approve from another withrein approval grant --run-id <id>. - Logs — per-run audit log (token-redacted):
~/.local/state/rein/audit/; direct-mode credential helper:~/.local/state/rein/helper.log.
Three layers, from hermetic to live:
-
Unit / hermetic — the Go suite. No network, no sandbox, no secrets; safe anywhere:
go test ./... go test -race ./... # the concurrency-sensitive packages
-
Gated sandbox e2e — actually launches
srtto prove the deny-read + seccomp self-test applies on this machine. Needs the sandbox stack healthy (rein doctor); off by default sogo test ./...stays hermetic:REIN_SANDBOX_E2E=1 go test ./internal/srt -run E2E -
Interactive (pexpect) suite — drives the real
reinbinary through a pty against a live throwaway repo + a working App: the write-approval loop and a real-agent (claude) end-to-end run. Prereqs: a machine set up viarein init(the suite resolves your App fromstate.jsonand its throwaway from the dev-session), the sandbox stack, hostghauthed, andpython3+pexpect. Seetests/interactive/README.md.tests/interactive/run.sh # whole suite (deps-light; no real claude) tests/interactive/run.sh test_write_approval # one module tests/interactive/run-journeys.sh --sandbox # the real-agent e2e (runs one claude) + sandbox invariants
The interactive suite is never run by
go test ./...(no.gofiles undertests/interactive/), so the Go suite stays fast and offline.
- Linux only. macOS (a different sandbox backend and CA-trust path) is a separate track, not yet done (design §5.4).
- Throwaway repos only, for now. The sandbox closes the credential- exfiltration gap, but the spine hasn't been dogfooded on a real repo yet; crossing that line is a deliberate step, not a default.
- Same-UID residual. The sandbox stops the agent. A separate process running as your own user on the host can still reach rein's proxy socket and your ambient credentials — that's outside rein's threat model (host hygiene). rein defends against a prompt-injected agent, not against malware already running as you. See design §5.3.
- The sandbox is defense-in-depth, not a hard boundary — an
srtescape re-exposes the direct-mode surface. One layer, honestly stated.
- Delete the Apps you created at https://github.com/settings/apps (GitHub has no API to delete an App).
- Remove
~/.config/rein/(keys, CA, state, session) and~/.local/state/rein/(shims, logs, audit, caches). Per-run proxy sockets live under$XDG_RUNTIME_DIR/rein/and are removed when the run exits. - Remove the
~/.local/bin/reinsymlink and the# BEGIN/END reinalias block from your shell rc (or~/.config/fish/functions/claude.fish).
