Security model
Rusuban runs Claude Code agents on your machine, with your logins, on repos where anyone can open an issue or a PR. This page says what holds the agents, how the dashboard checks who is asking, and what stays at risk.
What is trusted
- You, the owner: your instance's
config.json, your commands, your answers on cards. - The rules on each repo's default branch: someone with write access merged them.
- Nothing from GitHub. Issues, PRs, comments, reviews, diffs and a PR's own copy of the rules are data, never instructions.
- Not the agents either. They are told the rules and the safety frame, then held by limits that do not depend on their obeying.
Only the server merges
- Agents have no merge command, and the deny rules forbid the merge commands and APIs.
- The server merges after its gates, computed on live GitHub state, with
--match-head-commit: exactly the commit it checked, or nothing. - A PR merged by someone else, and a commit pushed straight to a default branch, are flagged.
The built-in deny rules
Every run (judge, coordinator, worker, task, reply) gets them as --disallowedTools, under either
host profile. Your own permissions.deny adds to them; nothing removes them.
| What | Denied |
|---|---|
| Merging | gh pr merge; any gh api call whose text holds merge or Merge (the REST merge endpoints, GraphQL merges, auto-merge). |
| Pushing to a default branch | git push --all and --mirror; gh api on git/refs/heads/<default branch>; what would skip the pre-push hook below: git push --no-verify, and any command that names hooksPath or GIT_CONFIG. Without that hook (git before 2.31), git push naming a repo's default branch (main, :main, +main, refs/heads/main), on any remote. |
| The owner's commands | rusuban project, task, start, permissions, machine, channel, remote, service, init, mcp, and Rusuban's MCP server (mcp__rusuban). The CLI and the MCP server also refuse to run inside a run. |
| The instance's state | Edits to config.json, config.json.seal, automerge.json, projects/, runs/, hub/, leases/, tasks/ and bin/ in the instance home. Agents keep their own folders there: scratch/, worktrees/, repos/, and work/ by default. |
| The dashboard and your files, from the shell | Any shell command that names the dashboard's port (:<port>) or a tailnet address (.ts.net), config.json next to the home's name, automerge.json, answers.jsonl or pending.json. A speed bump, not a wall: see the residual risks. |
A denied call is refused, and the run's log says so.
No push to a default branch
Every git a run starts takes Rusuban's hooks (core.hooksPath, set through GIT_CONFIG_COUNT).
Before anything leaves, git hands the pre-push hook the URL and the refs it resolved, so a remote's
name, a URL, a refspec, an alias or a script cannot hide where a push goes.
- Refused: a branch named like a project repo's default branch, on every remote. While a repo's
default branch is not read yet,
mainandmastertoo. - The one exception: an open PR's head on a contributor's fork, even named
main. GitHub lets a maintainer push there, and it is the contributor's branch, not the project's. - The repo's own hooks still run, after Rusuban's, as git would have run them. Only
reference-transaction,post-index-changeandpush-to-checkoutdo not. - A hook that cannot read the instance refuses the push.
Only your settings reach a run
Runs load Claude Code's user settings only (--setting-sources user). A repo's .claude/ settings,
with their hooks and permissions, never load: a hook or an allow rule merged into a repo does nothing
on your machine. Your own user settings do load: their allow rules add to an allowlist run, and their
deny rules and hooks apply. With a person's desktop registered, Rusuban adds one hook of its own:
rusuban guard refuses that desktop to a run that does not hold its lease.
The host profile
What agents may run on this machine is yours to choose, in config.json, and no repo, rule or PR
changes it. permissions.mode has no default: the config does not load without it.
permissions.mode |
Agents may run |
|---|---|
allowlist |
What permissions.allow lists, plus rusuban itself. Anything that would ask for approval is refused (--permission-mode dontAsk). |
bypass |
Anything, without asking (--dangerously-skip-permissions), except what a deny rule matches. |
Under bypass, an agent has your shell. What your account can do on this machine, an agent can
do: read and write your files, use your SSH keys, your gh login and every other tool you are logged
into, reach your network. The deny rules are the only limit, and they match command text.
Under allowlist, an allow rule for an interpreter or a build tool (Bash(node *), Bash(npm *),
Bash(python *)) is a shell too: it runs whatever code it is given.
The dashboard's identity check
- Loopback only. The server listens on
127.0.0.1. - Every request, reads included, must name this server as its
Host:127.0.0.1:<port>,localhost:<port>, or this machine's tailnet name. That stops DNS rebinding. The tailnet name comes fromtailscale status --json, read at start and every 5 minutes. - From the tailnet, the caller must be you. The
Tailscale-User-Loginheader, which Tailscale Serve sets from the caller's identity, must equalowner.tailscaleLogininconfig.json, case aside. A request without it is refused, and so is every tailnet request whileowner.tailscaleLoginis not set. - On loopback, a request without that header is the local user.
- Every write also needs the dashboard's own origin: an
Originofhttp://127.0.0.1:<port>,http://localhost:<port>orhttps://<tailnet name>, thex-rusuban: 1header, and a body under 8 KB. A page on another site cannot send that header without a CORS preflight, which the server never grants.
Remote access goes through Tailscale Serve (tailscale serve --bg --https=443 http://127.0.0.1:<port>),
never through Tailscale Funnel or any public address.
The approval bypass
A branch that requires an approving review refuses the server's merges until someone approves, and the agents' PRs, pushed under your account, cannot get your approval. The approval bypass is for that.
- Off for every project until you run
rusuban project bypass <id> on, which setsbypassApprovalinconfig.json. No repo, rule, PR or agent sets it. - It merges only what the server merges anyway: with the merge switch armed,
every gate green or your merge order given. It then merges with
gh pr merge --admin. --adminskips every rule of the branch, so the server first reads the base branch's rulesets, its classic protection in full, and the head's checks. It merges only if:- every check the branch requires is on the head, from the app the rule names if it names one, and
successin its latest run, checks the locks ignore included; - the merge method is one the branch allows, and not
mergeunder a linear-history rule; - the branch requires neither a code owner's review nor PRs up to date with it, and no thread is open where it requires them resolved;
- classic protection requires no signed commits, does not bind administrators, restrict who can push or lock the branch;
- the branch holds no rule or protection setting the server does not know.
- every check the branch requires is on the head, from the app the rule names if it names one, and
- Journaled. What stops the bypass is journaled once per head and flagged. Turning it on is journaled and flagged; turning it off is journaled.
What is flagged
These go to the journal and to your inbox's "Flagged for you", for 7 days:
- a merge the server did not make, and a commit pushed straight to a default branch;
- a merge GitHub refused, and what stopped the approval bypass;
- a rules change, with its diff, and a
locks.jsonthat does not validate; - the approval bypass turned on, and every other setting that widens;
- a
config.jsonchanged outside Rusuban's settings; - a task that did not end well.
Residual risks
- Under
bypass, an agent has your shell, and it reads text anyone can write. A prompt injection in an issue, a comment or a diff is a path to your shell. The safety frame tells agents that GitHub content is data; it is a prompt, not a wall. - Deny rules match command text. A merge, or a write through GitHub's API, hidden behind a
variable, an alias, a script, an interpreter or a raw HTTP call is not matched. A
git pushis checked where git resolved it, but an agent can still skip the hook: another spelling of--no-verifyor of the config, or a git that does not inherit the run's environment. The server flags a merge or a push it did not make, after the fact; it does not prevent it. - An agent's shell reaches what the deny rules do not cover. The state's deny rules bind Claude
Code's file tools; against the shell there are only the cheap rules above, which match text. The
dashboard takes any request from this machine as yours: its identity check stops other machines and
web pages, not local processes, which can send any
Originandx-rusuban. Underbypass, a shell command that gets past the rules can writeconfig.json, to add a repo or turn a project's approval bypass on, or call the dashboard, to answer a card (a merge order included), allow a setting's card (one asked through the MCP included) or arm the switch. The daemon journals the dashboard's calls as yours; the bypass turned on is flagged. Aconfig.jsonchanged outside Rusuban's settings is flagged too, with what changed, after the fact: the daemon applies it all the same. - The real barrier comes after v1: agents under an OS account of their own, or in Claude Code's
sandbox where it exists, out of reach of your files and the dashboard; and a GitHub token reserved
for agents, without admin rights. Until then, a repo you do not trust gets the
allowlistprofile. - A worker runs the code it works on, in a worktree, under your account: the repo's tests and scripts, and a contributor's when it rebases, finishes or measures their PR. The rules decide which PRs agents work on; the host profile decides what they may run.
- Agents act as you on GitHub. They use your
ghlogin: what they post appears under your account, and an agent past the deny rules has every right your token has. - A merge order overrides the project's locks for its head; a paused switch still holds it. An agent wrote the card: read it before you pick its merge option.
Tests
Each lock has a test that tries to break it:
- the gates, and adversarial PRs (the rules edited in the PR, injected instructions, red CI, a moved
head, a locked path, a missing proof):
test/merge-gate.test.mjsandtest/fixtures/prs/; - the deny rules, under both profiles:
test/agents.test.mjs; - the identity check and forged writes:
test/server.test.mjs; - the approval bypass and the owner's commands:
test/bypass.test.mjs.