terminal-agent
README.md
# terminal-agent
Local daemon for the **terminal MCP bridge**. It connects *outbound* over a
WebSocket to `wss://mcp.lishuyu.app/mcp/terminal/ws`, receives tool calls
that originate in claude.ai (or chatgpt.com), runs them against the local
file system, and returns results — **after sanitizing every byte** so that
no token or credential can ever reach chat history.
```
claude.ai ──(MCP, OAuth)──▶ mcp.lishuyu.app/mcp/terminal
│ TerminalBridge DO
│ WebSocket (this agent dials in, PSK auth)
▼
terminal-agent ──▶ local files
│ ┌────────────────┐
│ │ secret filter │ ← every output, before it leaves
│ └────────────────┘
```
## The hard rule
Defense in depth, every layer on the machine, before any byte leaves:
1. **Read containment** — `read_file`/`search_files`/`list_directory` are
confined to `allowed_read_dirs` (default `~/Codes`), checked *after*
resolving symlinks. Files outside the allowlist (`/etc`, `~/.config`,
`~/.ssh`, another project) are simply unreadable. This is the primary
boundary.
2. **Path blocklist** — within the allowed roots, credential files
(`.env*`, `.ssh/**`, `*.pem`, `*.key`, `*secret*`, `*credential*`,
`.aws/**`, `.kube/**`, `.pgpass`, …) are still refused (case-insensitive,
symlink-resolved). Always on, not user-disableable.
3. **`sanitize()`** — content regexes for `sk-…`, `ghp_…`, JWTs, PEM blocks,
64+ hex, `password: …`, URL credentials, etc. + your `secret_literals`,
applied to *all* tool output and error messages. Over-redaction (a git
SHA gets `[REDACTED]`) is preferred to any leak.
4. **Write confinement** — writes are confined to `allowed_write_dirs`,
realpath-checked, with symlinked leaves/dirs rejected.
5. **No shell + a tight read-only command whitelist** (see below).
## Tools
Read-only (default mode):
| tool | what |
|------|------|
| `terminal_status` | is a machine connected, and in which mode |
| `search_files(pattern, path?, glob?)` | ripgrep, `file:line:match`, ≤50 matches |
| `read_file(path, line_start?, line_end?)` | line-numbered; head+tail for big files; ≤200-line ranges |
| `list_directory(path?, max_depth?)` | tree, excludes node_modules/.git/… |
| `run_command(command)` | single read-only command, **no shell** (no pipes/redirection/substitution). Whitelist is metadata-only: git *metadata* subcommands (log/status/rev-parse/ls-files/…, with patch flags `-p`/`-u`/`-L`/`--patch` and write/exec flags rejected), plus ls, stat, wc, du, df, ps, uptime, uname, … Binaries that read file *contents* (cat/grep/head/`file`/git show/diff/blame/cat-file/`git log -p`) or spawn processes / write files (find/rg/tree) are excluded — use read_file/search_files. Every path arg is blocklist-checked **and** confined to `allowed_read_dirs`. |
Read-write / bypass (opt-in via `switch_mode`):
| tool | what |
|------|------|
| `write_file(path, content, mode?)` | confined to `allowed_write_dirs`; for handing off a HANDOFF.md/spec to local Claude Code |
| `switch_mode(mode, confirmation_code)` | enter read-write/bypass; needs the 6-digit code printed in THIS terminal at startup |
`run_command` deliberately does **not** whitelist `python -c` / `node -e` /
`perl -e`: those are arbitrary code execution and would defeat read-only.
Opt in per-binary via `extra_read_binaries`, or use `bypass` mode, only if
you accept the risk. Even in bypass mode the agent runs shell-free and
rejects pipes/redirection/`$()`/backticks — for a real pipeline, use a real
terminal.
### `switch_mode` is the anti-prompt-injection gate
The agent generates a random 6-digit code on startup. Entering read-write or
bypass is a two-step gate:
1. **Request** — the cloud calls `switch_mode(mode)` *without* a code. The
agent fires a **macOS notification** (`notify_on_switch: true`) carrying the
code to your Mac, and tells the cloud "ask the operator for the code."
2. **Confirm** — you read the code off the notification and relay it; the cloud
calls `switch_mode(mode, confirmation_code)` to apply.
A prompt-injected assistant cannot see your Mac's notifications (or your
terminal), so it cannot self-escalate — you hand it the code only when *you*
want to enable writes. The code rotates on every restart. (Off macOS, or with
`notify_on_switch: false`, the code is read from the startup banner/log.)
**Elevation is temporary.** read-write/bypass auto-reverts to read-only after
`elevation_timeout_ms` (default 10 min; each switch resets the clock), and you
get a notification when it does. A forgotten elevation can't stay open — and a
launchd restart also resets to the configured `mode:` (read-only). Set
`elevation_timeout_ms: 0` to keep it manual.
## Setup
Requires [Bun](https://bun.sh) (recommended) or Node ≥ 20.
```bash
cd ~/Codes/terminal-agent
bun install
cp config.example.yaml config.yaml # edit machine/cwd/allowed_write_dirs
# The pre-shared token must equal the Worker's TERMINAL_TOKEN secret.
export TERMINAL_AGENT_TOKEN='…' # put in ~/.zshrc or the launchd plist
bun run src/index.ts
```
On a successful connect you'll see `[ws] connected … registered`. In
claude.ai, add the connector `https://mcp.lishuyu.app/mcp/terminal`, then
call `terminal_status` to confirm the machine is online.
## config.yaml
See `config.example.yaml`. Key fields: `server`, `token` (`${ENV}` is
substituted), `machine`, `mode`, `default_cwd`, `max_output_bytes`,
`command_timeout_ms`, `allowed_write_dirs`, `blocked_paths` (extra globs,
added to the hard floor), `secret_literals`, `extra_read_binaries`.
## Run at login (launchd)
Copy `com.lishuyu.terminal-agent.plist.example` to
`~/Library/LaunchAgents/com.lishuyu.terminal-agent.plist`, fill in the
absolute paths, your token, and the Bun binary path, then:
```bash
launchctl load ~/Library/LaunchAgents/com.lishuyu.terminal-agent.plist
launchctl start com.lishuyu.terminal-agent
# logs → the StandardOut/StandardError paths in the plist
```
The confirmation code is in the agent's StandardOut log; `grep` it there
when you need to `switch_mode`.
## Tests
```bash
bun test # secret-filter + command-whitelist unit tests
bun run type-check
```
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues