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/sandbox-runtime@0.0.63(rein re-verifies on bump; other versions may move the injection hook). Needs Node 20+:npm install -g @anthropic-ai/sandbox-runtime@0.0.63bubblewrap,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. Two bounds re-lock writes, and neither ends the run — it ends when the agent exits. After 30 minutes with no GitHub traffic, or once the confirmation itself is 4 hours old (activity does not extend that one), rein revokes the write token and withdraws the confirmation in place: the agent keeps running, reads keep working, and its next write asks it to declare again for you to re-confirm. So you never restart the agent, and no run holds write capability more than 4 hours past its most recent confirmation. Tune the bounds withREIN_IDLE_TIMEOUTandREIN_APPROVAL_TTL(Go durations, minimum10s) — test/demo knobs, read only from your launch environment and never passed into the sandbox. - 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 three things: GitHub
(brokered through rein's proxy), the hosts the wrapped agent needs to start
(for claude: api.anthropic.com and platform.claude.com — its startup
preflight requires both, so they are allowed automatically and rein run -- claude works out of the box), and the dev egress preset: the package
registries and advisory hosts a dependency-fetching agent needs (Go modules,
npm, PyPI, crates.io, RubyGems, osv.dev). The preset is on by default so
go get / npm install / pip install work without setup; the launch banner
names it every run. Anything else — a remote MCP server, an internal host — is
unreachable until you allow it explicitly.
To turn the preset off (leaving GitHub, the agent's own API, and whatever you
list in allow_domains), set it to none in the session file, or machine-wide
via the environment (a session file's own egress_preset takes precedence):
egress_preset: noneexport REIN_EGRESS_PRESET=noneAdd other 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:
- mcp.example.com# or machine-wide, for every sandboxed run (comma-separated)
export REIN_ALLOW_DOMAINS="mcp.example.com,internal.example.com"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 you add (the curated preset's own wildcards
do not warn; the banner naming the preset is the disclosure). Keep the list
minimal and deliberate.
Since #185 rein itself is the sandbox's egress proxy (srt's external-proxy shape: srt keeps the network namespace unshared and bridges the sandbox's proxy port to rein; srt enforces nothing, rein decides every CONNECT). The allowlist and preset behave exactly as before, now enforced by rein, and there is a second mode for research and docs:
open_egress: true # or: rein session open-egress (off: rein session open-egress off)Open mode reaches any public host on port 443. It still refuses plain
http://, other ports (name host:port in allow_domains for one), and every
loopback, private, link-local, CGNAT and metadata address, the host's own
addresses, and rein's own ports, resolving each name first and pinning the
checked addresses at dial time. A private host you actually want (a build
server on the LAN) is a separate opt-in that never exempts loopback or metadata:
allow_internal_hosts:
- build.corp:443Open mode is a data-exfiltration surface by definition. The agent can send anything it can read (your checkout, its transcript, its own Anthropic credential) to any site, and everything it reads from the web is untrusted input. GitHub credential hiding and write brokering are unchanged; every host the agent contacts is in the run's audit log. The launch prints a fixed banner saying all of this; it cannot be suppressed, and there is deliberately no environment switch for open mode.
The proxy needs a per-run secret, which rein folds into HTTP_PROXY,
HTTPS_PROXY, ALL_PROXY and their lowercase forms inside the sandbox. Tools
that read only a tool-specific proxy variable srt also sets (FTP_PROXY,
RSYNC_PROXY, DOCKER_*_PROXY, CLOUDSDK_PROXY_*, GRPC_PROXY,
GIT_SSH_COMMAND) get a 407 or a port refusal rather than a bypass; point
them at $HTTPS_PROXY if you hit one.
The sandbox has its own network namespace, so a dev server the agent starts on
127.0.0.1:5173 is invisible from your browser. Declare the ports you want
forwarded and rein bridges them: your http://localhost:5173 reaches the
agent's 127.0.0.1:5173, for that run only.
expose_ports:
- 5173rein session expose-port 5173 # same thing, from the CLI; next run picks it upPorts are operator-declared, never agent-chosen (the agent cannot squat a
port you associate with something else), bound on your loopback only, and fail
the launch closed if something on the host already listens there. The bridge is
agent-initiated through the same broker socket everything else rides (srt's
seccomp filter blocks the alternative), never carries a GitHub token, and its
parked-stream pool is bounded. The agent's contract names the forwarded ports.
What you open is agent-written code running in your browser, exactly as if
you had run it on the host yourself — including that page's unrestricted egress.
Browser cookies are not port-scoped, so a page on localhost:5173 shares the
cookie jar of every other localhost app you use and can reach other loopback
services from a trusted-looking origin; if that matters to you, open it as
http://<anything>.localhost:5173 for a separate cookie jar.
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).
