claude-bridge
by Koragan
README.md
# claude-bridge
Ever wanted to tell Claude to do something on your PC while you're away?
It's a really bad idea, but here's a way to do it anyway: setting up a
custom MCP connector. It requires a fair number of moving parts, but it's
doable.
Remote-controlled shell/GUI access to your own machine from Claude (or any
MCP-speaking client), gated by phone approval and audit logging. Every
action — reading a file, running a command, logging into an app — requires
an explicit tap on your phone via [ntfy](https://ntfy.sh). Root-level
commands additionally require a TOTP code.
This is **not** a toy. It gives an AI agent the ability to run real
commands as root on your machine, once you approve it. Read the
[Security model](#security-model) section before you deploy this anywhere.

> Unofficial, community project. Not affiliated with or endorsed by
> Anthropic. "Claude" and "MCP" are used here only to describe
> compatibility.
## Quickstart
```bash
git clone <this-repo-url> claude-bridge
cd claude-bridge
cp .env.example .env # edit CLAUDE_BRIDGE_PUBLIC_URL once you have a hostname
./setup.sh # generates secrets, prints your OAuth password + TOTP QR
docker compose up -d # brings up the OAuth/MCP server + bundled ntfy
sudo ./agent/install-agent.sh "$(pwd)/data" # installs the native executor
```
That's the "brain" (containerized, no host access) and the "agent" (native,
the only piece that actually touches your machine) both running. Now:
1. Put a reverse proxy or tunnel (Caddy, nginx, Cloudflare Tunnel, Tailscale
Funnel, whatever you already use) in front of port 8000, and set
`CLAUDE_BRIDGE_PUBLIC_URL` in `.env` to that public hostname *before*
running `setup.sh`/`docker compose up` (it becomes the OAuth issuer and
the JWT's `aud`/`iss` claims — it must match exactly what you expose).
2. If your phone needs to reach ntfy from outside your LAN, expose port
8091 too (same proxy/tunnel), and subscribe to the notify topic printed
by `setup.sh` in the [ntfy app](https://ntfy.sh/).
3. In claude.ai: **Settings → Connectors → Add custom connector**, URL
`https://your-hostname/mcp`, authorize with the password `setup.sh` printed.
4. Ask Claude to do something. Approve on your phone. Done.
## Architecture
The core design decision: **the container never touches your host.**
Everything that needs real host access runs natively, outside Docker,
installed by a plain shell script you can read top to bottom.
```
┌─────────────────────────── "brain" (Docker) ───────────────────────────┐
│ server.py OAuth 2.1 + PKCE, and the MCP endpoint (merged into │
│ one ASGI app -- no separate reverse proxy needed) │
│ agent_server.py Unix-socket listener: submit/status/list/cancel/ │
│ confirm_totp │
│ control_listener subscribes to ntfy, processes Approve/Deny │
│ status_server.py read-only dashboard + JSON API │
└──────────────────────────────┬──────────────────────────────────────────┘
│ shared bind-mounted ./data volume
│ (queue.db, agent.sock, secrets)
┌───────────────────────────────┴──────────────────────────────────────────┐
│ "agent" (native, installed by agent/install-agent.sh) │
│ executor.py polls the SAME queue.db, actually runs approved │
│ requests as a dedicated system user, with a │
│ narrowly-scoped sudo rule for the exec_sudo tier │
└────────────────────────────────────────────────────────────────────────┘
```
A request's life cycle: MCP tool call → written to the shared queue →
ntfy push notification → you tap Approve → `control_listener` records the
decision → (exec_sudo: you also submit a TOTP code) → the native
`executor.py` picks it up and actually runs it → result written back to
the queue → Claude reads it via `check_status`.
Every request carries a `content_hash` (sha256 of
action+detail+path+window_name+id) that approval/TOTP steps must match
exactly, so an approval for one request can never be replayed against a
different one.
## Action tiers
`run_command(action, summary, detail, path, window_name, timeout_seconds)`:
| action | uses | notes |
|---|---|---|
| `read` | `path` | reads a file directly, no shell, capped at 300KB |
| `write` | `path` + `detail` (new content) | approval notification shows a unified diff |
| `exec` | `detail` (shell command) | runs as the agent user; set `CLAUDE_BRIDGE_DISPLAY` to let it launch GUI apps (see below); default timeout 60s |
| `exec_sudo` | `detail` (shell command) | runs as root via a denylist-backed sudo wrapper; default timeout 120s; **requires a TOTP code after phone approval** |
| `gui_type` | `detail` (text to type) + `window_name` (required) | types into a specific already-open window via `xdotool`; refuses if no window matches; the typed text is never logged or shown in the notification -- only its length and target window |
TTLs before auto-expiry: `read`/`write` 600s, `exec`/`exec_sudo` 300s,
`gui_type` 180s. `timeout_seconds` overrides the exec/exec_sudo default
(max 1800s).
## MCP tools
- `run_command(...)` — submit a request, returns `{id, status: "pending", expires_at}`
- `check_status(request_id)` — poll one request
- `list_pending()` — everything still pending or awaiting_totp
- `cancel_request(request_id)` — withdraw your own not-yet-executed request
- `confirm_totp(request_id, code)` — submit the TOTP code for an `exec_sudo`
request stuck at `awaiting_totp`, entirely from chat. This is what makes
the whole thing phone-only capable — no terminal needed for the sudo tier.
## GUI apps (optional)
To let `exec`/`gui_type` interact with a real desktop session:
1. On the desktop, grant the agent user X11 access (re-run this every
login, e.g. via your desktop environment's autostart):
```
xhost +SI:localuser:claude-bridge-agent
```
2. Uncomment `CLAUDE_BRIDGE_DISPLAY=:0` in
`/etc/systemd/system/claude-bridge-agent.service` and
`systemctl daemon-reload && systemctl restart claude-bridge-agent`.
Understand what this means: the agent user can now draw on and interact
with whatever's on that real, logged-in desktop. This is a deliberate
capability, not a default — don't enable it unless you actually want it.
## Security model
- **Nothing executes without a phone tap.** No exceptions, no bypass flags.
- **`exec_sudo` additionally requires a TOTP code** after the phone
approval — two independent factors. The TOTP secret lives only in
`data/totp_secret.json`.
- **Approval/cancel/TOTP steps are bound to `content_hash`**, preventing
cross-request replay (`cancel_request` is the one exception, by design —
it can only move a request further from execution, never closer).
- **`sudo_runner.py`** is the entire sudo attack surface for the agent user
— root-owned so the agent can execute it (via the scoped `NOPASSWD`
sudoers rule `install-agent.sh` installs) but never modify it. It has a
denylist regex for catastrophic patterns (`rm -rf /`, raw-device
`dd`/`mkfs`, fork bombs) as a last-resort backstop — the real boundary
is the approval pipeline upstream of it.
- **The brain container has zero host access.** No privileged mode, no
host PID namespace, no docker socket mount. If it's fully compromised,
the blast radius is "attacker controls your OAuth server and can submit
fake requests to the same approval queue" — which still requires your
phone tap (and TOTP, for root) to do anything.
- **Cross-UID data sharing**: the brain container and the native agent are
different UIDs sharing one bind-mounted directory. `install-agent.sh`
and the app code both default to permissive (`777`/`666`) permissions on
that directory as the pragmatic default for a single-host setup. If you
want tighter isolation, put both under a shared group instead and adjust
accordingly.
- **GUI access is opt-in and explicit** (see above) — a compromised or
buggy agent process with `CLAUDE_BRIDGE_DISPLAY` set could interact with
whatever's on the real desktop session.
## Configuration reference
All via `.env` (read by `docker compose`) or `docker-compose.yml`'s
`environment:` block:
| variable | default | what |
|---|---|---|
| `CLAUDE_BRIDGE_PUBLIC_URL` | `http://localhost:8000` | your externally-reachable hostname, no path suffix — becomes the OAuth issuer/audience |
| `CLAUDE_BRIDGE_PORT` | `8000` | host port for the MCP/OAuth endpoint |
| `CLAUDE_BRIDGE_STATUS_PORT` | `8092` | host port for the status dashboard |
Native agent (`agent/install-agent.sh` writes these into the systemd unit):
| variable | what |
|---|---|
| `CLAUDE_BRIDGE_DB_PATH`, `CLAUDE_BRIDGE_AUDIT_LOG`, `CLAUDE_BRIDGE_HEARTBEAT_PATH`, `CLAUDE_BRIDGE_SOCK_PATH` | paths into the shared `./data` directory |
| `CLAUDE_BRIDGE_SUDO_RUNNER`, `CLAUDE_BRIDGE_AGENT_PYTHON` | how the exec_sudo tier invokes `sudo_runner.py` |
| `CLAUDE_BRIDGE_DISPLAY` | optional, e.g. `:0` — enables GUI apps (see above) |
## Troubleshooting
- **`sqlite3.OperationalError: attempt to write a readonly database`** —
the queue.db was created by one side (container or native agent) with
permissions the other side's UID can't write to. `bridge_queue.py`
chmods it to `666` on every `init_db()` call, but if you're still
hitting this, check `ls -la ./data`.
- **`PermissionError` reaching the data directory from the native
agent** — the shared data directory needs to be *traversable* by the
agent user through every parent directory, not just have open
permissions itself. Putting your checkout inside a locked-down home
directory (some distros ship `~` as `drwx--x---`) will break this even
if `./data` itself is wide open. Either put the project somewhere
globally traversable (e.g. `/opt/claude-bridge`), or add the agent user
to a group with access to your home directory.
- **ntfy: "auth is unconfigured for this server"** — `NTFY_AUTH_FILE` must
be set for `ntfy user add` to work at all; `docker-compose.yml` already
sets this, but if you changed it, check that first.
- **SELinux (Fedora/RHEL/etc): containers failing to write to bind
mounts** — the compose file already appends `:z` to the volume mounts.
If you added new mounts, do the same, or you'll see `avc: denied`
entries in `journalctl` / `ausearch -m avc`.
- **Fresh chat claims a tool (usually `confirm_totp`) doesn't exist** —
claude.ai caches the tool list per connector session, not per
conversation. Fully disconnect/reconnect the connector in Settings →
Connectors rather than just starting a new chat.
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues