# FarHand Gives a coding agent hands on a remote machine and keeps its hands off the local one. **https://farhand.cogflux.io** FarHand is an MCP server. Point OpenCode, Claude Code or Codex at it, and every command the model runs and every file it reads or writes happens on a remote host over your existing SSH setup. The local machine stays closed, with one exception you control: a list of local folders the model may look into and upload from. Nothing that looks like a credential is ever sent to the remote, and everything that happens is written to a local audit log. ``` ┌─ OpenCode ─┐ ┌─ Claude Code ─┐ ┌─ CogFlux (later) ─┐ │ thin plugin │ │ .mcp.json │ │ --mcp-config │ └──────┬──────┘ └──────┬────────┘ └─────────┬─────────┘ └─────────────────┴── MCP over stdio ────┘ farhand (one binary) ┌──────────────────┼───────────────────┐ remote tools local tools audit log remote_shell local_ls / local_read one JSON line per action remote_read/write upload / download remote_edit/ls (allowlisted folders only) remote_glob/grep remote_spawn/jobs/kill │ your OpenSSH: ~/.ssh/config, keys, agent, jump hosts — one multiplexed session ``` ## Why not `opencode serve` on the remote? That works, but the agent's provider keys and configuration have to live on the remote, and every agent needs its own way of doing it. FarHand keeps the agent — and its credentials — local, and gives every MCP-capable agent the same remote hands. ## Install Local: macOS or Linux with OpenSSH. Remote: Linux with `bash`, `timeout` (coreutils), `base64`, an SFTP subsystem (default in sshd), and ideally `ripgrep` for `remote_glob` / `remote_grep` (both fall back to `find` / `grep`). Windows is not supported: FarHand relies on OpenSSH connection multiplexing over a Unix socket. **Prebuilt binary** (no Rust needed) — one file into `~/.local/bin`, checksum verified: ``` curl -fsSL https://raw.githubusercontent.com/CogFlux/farhand/main/install.sh | sh ``` Or download `farhand--.tar.gz` from the [releases page](https://github.com/CogFlux/farhand/releases) yourself, or `cargo binstall farhand`. **npm** (Node 18+) — a dependency-free wrapper that fetches the same release binary on first run, checksum verified: ``` npm i -g farhand ``` `farhand install` then writes the wrapper's entry, not the binary, into the agent's config, so `npm i -g farhand@latest` upgrades the agents too (the first run after an upgrade downloads the new binary); the agent needs `node` on its `PATH`. `npx farhand init` and `npx farhand check` are fine for a try, but `farhand install` refuses to run from npx's cache (or `pnpm dlx`, `bunx`), which is cleared behind the agent's back. **From source** (Rust stable): `cargo install --path crates/farhand` puts `farhand` in `~/.cargo/bin`; or `cargo build --release` and point `FARHAND_BIN` at `target/release/farhand`. Then, either way: ``` farhand install opencode # and/or claude-code, codex — once per machine cd ~/my-project farhand init > .farhand.toml # edit host, workdir, allowed_dirs farhand check # connects once and reports ``` That is all: a project is remote exactly when it has a `.farhand.toml`. There is no global configuration to write unless you want one (see Configure). See `docs/USAGE.md` for the step-by-step guide and per-agent configuration. ## Update `farhand --version` says which version you have. Update the way you installed: | Installed with | Update with | |---|---| | the install script | the same `curl … \| sh` again | | npm | `npm i -g farhand@latest` | | `cargo binstall` | `cargo binstall farhand` | | `cargo install` | `cargo install farhand` | | a checkout | `git pull && cargo install --path crates/farhand` | Then run `farhand install ` once more for each agent you use (it changes nothing if nothing changed: the OpenCode plugin and Codex's approval settings are written from the binary at install time), and restart the agent, whose running server is still the old one. ## Configure `farhand init` prints a commented template. The essentials: ```toml [remote] host = "devbox" # an alias from ~/.ssh/config, or user@host workdir = "~/proj" # commands start here; relative paths resolve here; created if missing [local] allowed_dirs = ["."] # the only local folders the model may see; "." = this folder ``` With `"."` the folder holding `.farhand.toml` is the project's local pocket: drop a file in it and the model can `upload` it, and `download` lands there. Nothing else lives in that folder — the code is on the remote — so this is the natural default. Absolute paths and `~/...` work too; relative entries are only accepted in a project `.farhand.toml`, never `..`, `/` or `~`. The remote may be Linux, macOS, BSD — or **Windows** with OpenSSH Server, where commands run in PowerShell (`os = "windows"` under `[remote]` tells the model so up front; the platform is detected on connect regardless). Directory creation and tree walking go over SFTP, so uploads, downloads, `remote_write` and `remote_edit` behave the same everywhere. Transfers are streamed with many requests in flight, so a large file moves at link speed and memory stays flat whatever its size; the default cap of 1 GiB per transfer is `max_transfer_bytes` under `[limits]`. Config is found in this order: `--config PATH`, `$FARHAND_CONFIG`, `.farhand.toml` in the current directory or the nearest folder above it, `~/.config/farhand/config.toml`. The per-project `.farhand.toml` is the normal one: it is what makes a directory, and every folder and worktree below it, remote. The search upwards stops short of the home directory and `/`, so a stray file there claims nothing. The global file is optional and serves two purposes: `activation = "always"` turns every directory on the machine remote using its `[remote]`, and it is a convenient target for `--config`. Without any config at all, FarHand is simply inactive: the agent keeps its local tools and the server offers none. ## Docker sandboxes The remote does not have to be another machine. A [Docker sandbox](https://docs.docker.com/ai/sandboxes/) (`sbx`) is a microVM on this one, and since `sbx setup ssh` it is an SSH target, so FarHand uses it with no extra code: the agent and its keys stay on the host, commands and files go into the VM. ``` sbx setup ssh # once: adds `Host *.sbx` to ~/.ssh/config sbx create shell --name myproj # no path: nothing of this machine is mounted ``` ```toml [remote] host = "myproj.sbx" workdir = "~/workspace" os = "posix" ``` Create the sandbox **without a workspace path**, or with `--clone` (the host repository is mounted read-only and the sandbox works on its own clone). A sandbox created with a path, as in `sbx create shell .`, shares that folder with this machine read-write: everything the model writes there lands on the local disk at once, past every check `download` makes. `farhand check` warns when a `.sbx` host has such a mount. Authentication is your Docker login, so no SSH key is involved; get files in and out with `upload` and `download`, or with `git` from inside the sandbox. ## Agents `farhand install ` does the wiring for `opencode`, `claude-code` and `codex` (`--scope user|project`); `farhand uninstall` reverses it and `farhand status` reports it. Both first show every change they would make — a diff for each file, the command line for each agent CLI call — and ask before making it; `--dry-run` only shows, `--yes` skips the question (and is required where there is no terminal to ask on). By default only directories that carry a `.farhand.toml` go remote (`activation = "project"`); everywhere else the agent keeps its local tools. `activation = "always"` in the global config makes every session remote. What each agent gets is in `docs/USAGE.md`. ## OpenCode `farhand install opencode` writes `plugins/opencode/farhand.ts` into `~/.config/opencode/plugins/` (or symlink it there yourself for development). The one file carries both plugin APIs, so it works with OpenCode 1.18.29+ and OpenCode 2 alike and survives an upgrade between them. The plugin registers the MCP server, removes OpenCode's local tools (`bash`/`shell`, `read`, `write`, `edit`, `patch`, `apply_patch`, `glob`, `grep`, `list`, the LSP tools, and in OpenCode 2 the `browser_*` tools, the experimental `tui_*` terminal tools and Code Mode's `execute`, since a local browser and `execute`'s `fetch` can both open `file://` URLs; OpenCode 1 gets them as `deny` permissions), refuses them again at call time as a second fence, and tells the model in the system prompt that it is working remotely. Approval is FarHand's knob, not the agent's: `[approval] mode = "ask"` (default) makes the agent prompt before `remote_shell`, `remote_write`, `remote_edit`, `remote_spawn`, `remote_kill`, `upload` and `download` and lets read-only tools run; `"auto"` prompts for nothing, with the audit log as the record; `"strict"` prompts for everything, reads included; `tools = { remote_write = "auto" }` overrides per tool. A project `.farhand.toml` can make this stricter but not looser than the global config; `auto` belongs in the global file. The plugin translates this into OpenCode's permission system at startup. Without the plugin, the same effect comes from `opencode.jsonc` (OpenCode 1 format shown; OpenCode 2 reads it too, but cannot remove `browser_*` from config alone — use the plugin there): ```jsonc { "mcp": { "farhand": { "type": "local", "command": ["farhand", "serve"], "enabled": true, "timeout": 60000 } }, "tools": { "bash": false, "read": false, "write": false, "edit": false, "glob": false, "grep": false, "list": false, "patch": false }, "permission": { "farhand_remote_shell": "ask", "farhand_remote_write": "ask", "farhand_remote_edit": "ask", "farhand_upload": "ask", "farhand_download": "ask" } } ``` The tools then appear as `farhand_remote_shell`, `farhand_upload`, and so on. ## Tools | Tool | What it does | |---|---| | `remote_shell` | Run a command on the remote in its own shell — POSIX shell, or PowerShell on Windows (bounded output, remote timeout, local watchdog). | | `remote_read` | Read a remote file with line numbers; `offset`/`limit` page through it. | | `remote_write` | Create or overwrite a remote file; parents are created. | | `remote_edit` | Replace an exact, unique string in a remote file (`replace_all` optional; CRLF files are matched with LF strings and kept CRLF). | | `remote_ls` / `remote_glob` / `remote_grep` | `ls -la`, `rg --files -g`, `rg -n` on the remote. | | `remote_spawn` | Start a long-running command (dev server, watcher) in the background, detached from the session so it outlives the call; output goes to a log under `~/.farhand/jobs/` on the remote. | | `remote_jobs` / `remote_kill` | List those jobs (running, or exited with their status), and stop one with the processes it started. | | `local_ls` / `local_read` | Look inside the allowlisted local folders. | | `upload` | Copy a local file or folder (allowlist only, guard-checked) to the remote. | | `download` | Copy a remote file or folder into an allowlisted local folder (never credential-shaped names or files a local tool would execute). | | `remote_info` | Host, workdir, connection state, allowed folders, audit location. | A command run by `remote_shell` lives as long as its SSH channel: Windows OpenSSH kills every process the session started when the command ends, and on POSIX a background child that keeps the output open holds the command until its timeout kills it. `remote_spawn` starts the job outside the session instead — in a session of its own (`setsid`, or `perl` where there is none) on POSIX, through WMI (`Win32_Process.Create`) on Windows — and records it on the remote, so a later FarHand session still lists and stops it. ## What never leaves this machine Every byte headed for the remote — command text, file content, edits, uploads, and every other argument (paths, `cwd`, search patterns and globs) — passes the secret guard first: - **by path** (case-insensitively): `.env*`, `*.pem`, `*.key`, `id_*`, `.netrc`, `.npmrc`, `credentials*`, `*service-account*.json`, `*.tfstate`, agent configs (`opencode.json*`, `.mcp.json`, `claude_desktop_config.json`, ...), and anything under `.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.config`, `.claude`, `.opencode`, `.codex`, ...; - **by content**: private-key blocks, `sk-…`, `ghp_…` / `github_pat_…`, `AKIA…`, `AIza…`, `xox…`, Stripe, JWTs, `Authorization: Bearer …`, URLs with embedded passwords, and `api_key = "<24+ random chars>"` style assignments (an unquoted variable or attribute chain such as `token = process.env.GITHUB_TOKEN` is code, not a credential, and passes). Add your own with `[guard] deny_globs` / `deny_content`. A refusal comes back to the model as a tool error naming the rule, never the matched text, and is audited the same way. There is no override flag. The local environment is never forwarded to the remote. Commands run in the remote user's own login shell. ## Threat model FarHand is built so that the model can be *wrong or hostile* and the damage stays on the remote. What holds regardless of which model is driving: - **No local execution.** The only process FarHand ever starts locally is `ssh` to the configured host (plus `ssh -G` to print its address). No model-supplied string reaches a local command line. - **Local reads are the allowlist, minus credentials.** `local_ls`, `local_read` and `upload` resolve paths through the allowlist with symlinks followed first, so a link out of the folder is refused. The file is then opened from a handle on its allowed folder one component at a time without following a symlink, so a link swapped in after the check (by a sync tool, an editor, another process) is refused too. Files the guard would refuse to upload are hidden from `local_ls` and refused by `local_read`: what the model cannot see it cannot re-encode. An upload is scanned again as it streams, and no byte leaves before the guard has seen it, so a file that changes after its first scan cannot slip through. `/`, the home directory and any folder above it are not accepted as allowed folders. - **Downloads cannot plant anything.** Every file a download would create is resolved through the allowlist again and checked by name (case-insensitively): nothing credential-shaped, and nothing an editor or agent executes or trusts on opening a folder (`.vscode`, `.idea`, `.git`, `.husky`, `.envrc`, `.claude`, `.farhand.toml`, `CLAUDE.md`, ...). A refused name aborts the whole download before the first byte is written. Folders on the way are opened and created without following symlinks, the data goes to a randomly named temporary file created exclusively in its folder, and is renamed into place within that same folder, so a link planted in the folder is replaced rather than written through. The size limit is enforced on the bytes actually read. - **Only hosts you have already met.** SSH host keys are checked strictly: a `.farhand.toml` that arrives inside a cloned repository cannot point the agent at a machine you never connected to. Connect once by hand first. - **No way back over SSH.** FarHand reads your ssh config through a wrapper that clears every port forwarding (`RemoteForward`, `LocalForward`, `DynamicForward`) and turns off agent, X11 and GSSAPI credential forwarding for its connection, whatever `~/.ssh/config` says for the host. Keys, jump hosts and everything else still come from your config. - **A project file can only tighten.** A `.farhand.toml` may arrive with a cloned repository, so it cannot loosen `[approval]` below the global config (or the default `ask`), allow local folders outside its own directory, or move the audit log. A relative folder must still be inside the project once symlinks are resolved, so an `assets` link to elsewhere is not a way out. Such settings are ignored, and `farhand check` and `farhand validate` say which. Put them in the global config instead. A global config that exists but does not load stops FarHand rather than being skipped. - **Everything is on record.** Every call, including refusals, is one line in the audit log. A log directory that cannot be written stops FarHand at startup, and a record that cannot be written later makes FarHand refuse every further call until it is restarted. What FarHand does not do: it cannot stop a model that has legitimately read a non-secret file in the allowlist from sending it to the remote (that is the point of the allowlist), it does not restrict the remote itself (treat the remote account as the model's sandbox and give it only what that account should have), and it does not disable *other* MCP servers or tools your agent has that reach the local machine. The agent integrations take the agent's own local tools away (Codex only at project scope), but that is the agent's setting, not a wall. Keep secrets out of `allowed_dirs`; the guard is a tripwire for accidents, not a substitute for that: a password, an archive or an encoded token can pass it. If the model must not reach the local machine even through a misconfigured agent, run the agent itself in a container or VM that mounts only the folder you allow. There is deliberately no local `curl`/fetch tool: `curl` reads local files (`-d @file`, `file://`) and reaches local-only services (Docker sockets, IDE and browser debug ports, `localhost` dashboards), and an approval prompt is not a defence against a request that looks harmless. Fetch from the remote with `remote_shell` instead; the remote has `curl` too. ## Audit log `~/.local/share/farhand/audit/YYYY-MM-DD.jsonl`, one object per action: timestamp, session, host, tool, command or path, byte count, outcome (`ok` / `denied` / `error` / `timeout`), exit code, duration. File contents are never logged, and a command the guard refused is logged as redacted. ## Layout ``` crates/farhand-core config, guard, local allowlist, remote (ssh + sftp), transfer, audit crates/farhand the `farhand` binary: MCP tools over stdio crates/farhand/plugins/opencode the OpenCode entry point (embedded in the binary) packages/npm/farhand the npm wrapper (`npx farhand`), fetches the release binary ``` ## Claude Code `farhand install claude-code` registers the server and adds a `PreToolUse` hook (`farhand hook claude-code`) that refuses local tools (`Bash`, `PowerShell`, `Monitor`, the file tools, `EnterWorktree`) wherever FarHand is active and answers `mcp__farhand__*` calls from `[approval]` on every call; project scope also writes a static `permissions.deny`. The hook is set to block the call if it fails or times out, rather than let it through. Tested at project scope. ## Codex `farhand install codex` registers the server, translates `[approval]` into per-tool `approval_mode`, and adds a `PreToolUse` hook (`farhand hook codex`) that refuses Codex's local shell, `apply_patch` and `view_image` wherever FarHand is active, plus a `SessionStart` hook that tells the model it works remotely. Codex runs the hooks only once you trust them in its `/hooks` view. `--scope project` also turns Codex's shell tool off and sets the sandbox read-only, which holds even if a hook fails. ## Roadmap - CogFlux integration: launched via `--mcp-config`, audit log as an ingest source. - Several remotes per config with a `host` argument on the tools.