termgate
v1.3.0
Published
Expose real shell sessions (bash/wsl/powershell) over a token-gated local HTTP + WebSocket API, with workspace confinement, a command risk policy, audit logging, and an MCP connector
Maintainers
Readme
termgate
Runs multiple real shell sessions (bash / wsl.exe / powershell.exe) on your machine, each controllable two ways:
- REST API (
/api/sessions/...) — list, create, run a command, read output, kill. Plainfetch()calls, no WebSocket client needed. This is the part a browser-based AI agent uses. - WebSocket (
/ws/:id) — live interactive stream, used by the built-in browser terminal UI (xterm.js) so a human can watch/type too.
1. Install
npm installnode-pty compiles a native module.
- WSL/Linux:
sudo apt-get install -y build-essential python3 - Windows native: install "Desktop development with C++" (VS Build Tools), then retry.
2. Run
npm startOutput:
UI: http://127.0.0.1:4123/?token=<random-hex-token>
API: http://127.0.0.1:4123/api/sessions (header: Authorization: Bearer <token>)One session named main is created automatically on startup.
3. Use from a Chrome-based AI agent
Point it at http://127.0.0.1:4123/?token=<token> to see/drive the terminal
UI directly, or have it call the REST API with plain fetch:
const BASE = 'http://127.0.0.1:4123';
const TOKEN = '<your-token>';
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${TOKEN}` };
// List terminals
const sessions = await fetch(`${BASE}/api/sessions`, { headers }).then(r => r.json());
// Create a new terminal
const s = await fetch(`${BASE}/api/sessions`, {
method: 'POST', headers, body: JSON.stringify({ name: 'build' })
}).then(r => r.json());
// s.id -> use below
// Run a command on it and get the output it produced
const result = await fetch(`${BASE}/api/sessions/${s.id}/run`, {
method: 'POST', headers, body: JSON.stringify({ command: 'npm run build', waitMs: 3000 })
}).then(r => r.json());
console.log(result.output);
// Poll for more output later (e.g. long-running command)
const more = await fetch(`${BASE}/api/sessions/${s.id}/output?since=${result.bufferLength}&token=${TOKEN}`)
.then(r => r.json());
// Kill it when done
await fetch(`${BASE}/api/sessions/${s.id}`, { method: 'DELETE', headers });CORS is open (Access-Control-Allow-Origin: *) so this works from any tab —
the token is the actual gate, not the origin.
API reference
All endpoints require the token, either as Authorization: Bearer <token>
header or ?token=<token> query param.
| Method | Path | Body | Purpose |
|---|---|---|---|
| GET | /api/sessions | — | List all sessions |
| POST | /api/sessions | {name?, shell?, cwd?, cols?, rows?} | Create a session |
| POST | /api/sessions/:id/run | {command, waitMs?} | Write command + newline, wait waitMs (default 1200, max 10000), return output produced in that window |
| POST | /api/sessions/:id/input | {data} | Send raw keystrokes, no auto newline (for Ctrl+C, interactive prompts, etc.) |
| GET | /api/sessions/:id/output?since=N | — | Full scrollback, or only what's new since char offset N |
| POST | /api/sessions/:id/resize | {cols, rows} | Resize the pty |
| DELETE | /api/sessions/:id | — | Kill the session |
WebSocket: ws://127.0.0.1:4123/ws/:id?token=<token> — sends
{"type":"data","data":"..."} frames, accepts {"type":"input","data":"..."}
and {"type":"resize","cols":N,"rows":N}.
Public access (--public)
For a remote or cloud-based agent (not local Chrome) that needs to reach
your terminal, --public spins up a Cloudflare Quick Tunnel
automatically and prints the public HTTPS URL alongside your token.
termgate --publicRequires cloudflared.
termgate now installs it automatically if it's missing — on first
--public run it tries the native package manager for your OS, and falls
back to downloading the official binary directly if that's unavailable:
| OS | Tries first | Falls back to |
|---|---|---|
| Windows | winget install --id Cloudflare.cloudflared | direct .exe download to ~/.termgate/bin |
| macOS | brew install cloudflared | direct .tgz download + tar extract to ~/.termgate/bin |
| Linux | — (no sudo/package manager assumed) | direct binary download to ~/.termgate/bin |
No manual install step needed in the common case. If both the package manager and the download fail (e.g. no internet, blocked GitHub access), it prints manual install instructions and the server keeps running locally.
By default --public waits 5 seconds before starting the tunnel so you can
Ctrl+C out if you change your mind. Skip that pause with --public --yes.
The server itself still only binds to 127.0.0.1 — cloudflared tunnels
to that local port, it doesn't change what the Node process binds to. The
public URL is randomly generated per run (https://xxxx.trycloudflare.com)
and dies when you stop the tunnel — there's no fixed address to leak long-term,
but the token in that session is still live for as long as the tunnel is up.
Treat --public as "anyone on the internet with this one URL+token gets a
shell on my machine" — because that's exactly what it is. Concretely:
- Don't leave it running unattended. Stop it (
Ctrl+C) the moment you're done with whatever remote agent needed it. - Set a fixed, intentional token via
TERM_TOKEN=before using--publicrather than relying on the random one — makes it easier to reason about who currently has access, and to rotate cleanly by restarting. - There's no rate limiting or command allowlist here. If you want that layer, it needs to be added on top (happy to build it — see the note in the termgate skill file).
Remote MCP connector (add termgate directly to claude.ai)
termgate exposes a proper MCP endpoint at /mcp (Streamable HTTP, stateless)
with tools: list_sessions, create_session, run_command, send_input,
read_output, kill_session. This lets you add it as a custom connector
in claude.ai itself, not just via Claude in Chrome browsing a webpage.
Setup:
- Run
termgate --public(or--public --yes) to get a public HTTPS URL. - In claude.ai: Settings → Customize → Connectors → Add → Add custom connector.
- URL:
https://<your-tunnel>.trycloudflare.com/mcp - Click Advanced settings and add a request header:
Authorization: Bearer <your-token> - Save, then enable it for a conversation via the + → Connectors menu in chat.
Read this before enabling it: claude.ai calls MCP connectors from Anthropic's cloud infrastructure, not from your device — so once this is added, this chat product itself can call real shell tools on your machine through the tunnel, from any device you're logged into. That's a step up from the Chrome-only path: no browser tab has to be open, and the connector stays configured until you remove it. Treat adding it the same way you'd treat handing out the token — because that's exactly what it is. Remove the connector (Settings → Connectors → remove) when you're not actively using it, and restart termgate to rotate the token between sessions.
Security model
Read this before pointing an agent (or the public tunnel) at termgate.
What's enforced (structured command path — REST /run, MCP run_command,
REST /input, MCP send_input):
- Workspace confinement. New sessions spawn in the folder you launched
termgate from (the "launch folder"), or a subfolder of it —
cwdoutside that is rejected. Override the root withtermgate --workspace /some/path. - Hard blocks. Known-destructive commands (
rm -rf /, disk format, registry edits, fork bombs, disabling Defender/firewall, etc.) are refused outright — they never reach the shell. - Confirm gate. Risky-but-not-catastrophic commands (
sudo, forced git operations, reading SSH/AWS credential files, download-and-execute pipelines, path traversal via../) are held and returnrequiresConfirmation: trueinstead of running — re-send withconfirm: trueto actually execute them. - Every decision is audit-logged — see below.
What's NOT enforced — audit-logged only (interactive typing path — the
browser terminal UI, raw WebSocket input messages):
Blocking a live interactive shell mid-command would mean withholding every
keystroke from the pty until Enter, which breaks live echo, backspace,
tab-completion, Ctrl+C, and any raw-mode program (vim, ssh, password
prompts). That's a fundamentally different, much harder engineering problem
than gating a structured "run this one command" call. So: interactive
typing is reconstructed into lines and logged with a risk label, but never
blocked. If someone has the token and a terminal tab open, they can type
anything a normal shell would accept.
This is pattern matching, not a sandbox. All of the above is text analysis on the command string — it can be bypassed with obfuscation (base64-encoded payloads, alternate interpreters, unusual quoting, multi-step indirection). It raises the bar against obviously destructive or careless commands and gives you a paper trail; it does not provide real isolation. For actual containment you'd need to run sessions inside a container or OS-level sandbox (Docker, a chroot/bwrap jail on Linux, Windows Sandbox) — a materially bigger project, since it also needs to decide what tools (git, npm, python, docker itself) are available inside that boundary. Worth doing as a follow-up if you need real isolation rather than a speed bump; ask and it can be scoped separately.
Audit log: every REST/MCP command (executed, blocked, or held) and every
reconstructed interactive line is written to
~/.termgate/audit/YYYY-MM-DD.jsonl, one file per day, kept for 30 days
(older files are deleted automatically on startup and once a day). View it
via the Audit Log tab in the browser UI, GET /api/audit, or the MCP
read_audit_log tool.
Config (env vars)
| Var | Default | Purpose |
|---|---|---|
| PORT | 4123 | Port to listen on |
| HOST | 127.0.0.1 | Bind address — keep as localhost, see security note |
| TERM_TOKEN | random on each start | Set a fixed token instead of a random one |
| SHELL_CMD | wsl.exe on Windows, $SHELL on Linux/WSL | Default shell for new sessions (override per-session via POST /api/sessions body too) |
CLI flags: --public (start a Cloudflare tunnel after boot), --yes (skip
the 5s confirmation pause when combined with --public), --workspace <path>
(confine sessions to a specific folder instead of the current directory).
Fixed token example:
TERM_TOKEN=my-fixed-token SHELL_CMD=/bin/bash npm startClaude Code skill
skills/termgate/SKILL.md documents the API for Claude Code so you can say
/connect localhost:4123 (plus the token) and have it drive sessions
directly. Copy that folder's contents into your project's .claude/skills/
(or wherever your Claude Code skill directory is set up) to enable it.
Read the "Important operating rules" section in that file before enabling it. Commands run this way execute immediately with no per-command confirmation — the token is the only gate, granted once at connect time, not per action.
Publishing to npm
- Rotate/generate a fresh npm access token — never reuse a token you've pasted anywhere in a chat or logged, treat any pasted token as compromised immediately.
- From this folder:
npm login # or: npm config set //registry.npmjs.org/:_authToken=<fresh-token> npm publish --access public - Confirm the name
termgateis still free right before publishing (npm view termgateshould 404) — someone else could take it in the meantime.
Security notes — read before using
- Whoever has the token gets a real, arbitrary shell on your machine. There's no user/permission model beyond the single token — every session runs as you.
- Keep
HOST=127.0.0.1. Don't bind0.0.0.0or forward the port to the internet as-is — this token check is a basic gate, not hardened auth (no rate limiting, no HTTPS, no per-session scoping). - CORS is wide open by design (so a browser agent tab can call it) — that means any page you have open could also call it if it somehow learned the token. Don't paste the token into untrusted pages or logs.
- For remote/cloud-based agents (not local Chrome), tunnel instead of
opening the port:
cloudflared tunnel --url http://127.0.0.1:4123orngrok http 4123, then use the HTTPS tunnel URL + token. Rotate the token after. - Kill sessions (or the whole process,
Ctrl+C) when you're done. Every open session is a live shell for as long as it exists.
