Docs › Security model
Security model
Sparehand lets one computer (the hub) run commands on others. That is exactly what an attacker would want, so the design keeps the trust in one direction and in as few places as possible.
Who trusts whom
- The hub treats everything a worker sends back as untrusted. Workers never connect to the hub; the hub logs in to them over SSH. What a worker sends back is command output (shown to you), its exit code, the status line (CPU, memory, job count…), and the files you ask for:
- Every field of the status line is checked before it is used: numbers must be digits only, the rest must be one of a few known words, and a line that doesn't fit makes that worker count as offline. The jobs it lists are shown as text with control characters removed.
- Files come back only where you ask for them (
run --get, and the files a session on a worker changed), and only into the checkout: never into a.git,.claude,.vscodeor.ideafolder (at any depth, in any letter case), never a dot-file or dot-folder at the checkout's top level (.envrc,.npmrc,.husky/,.github/…), never through a symlink here that leads elsewhere, and a symlink from the worker comes back only when it points inside what is copied. Those are the places where a file runs something on the hub without anyone reviewing it (git hooks, Claude Code and editor settings, shell hooks).SPAREHAND_PULL_DOTFILES=1lets a session's changes to everything but.gitcome back; leave it off for workers you don't control. A session lists what it left out. - A worker can still put anything in the files it is allowed to send, like the code a session changed or a test report: review a session's changes before you commit or run them, as you would anyone's.
- Workers trust the hub completely. Whoever can log in to a worker as your user can run anything there, so the worker's SSH must accept only the hub's key, from the hub's address (hardening).
- Worker-to-worker: nothing. Workers have no keys for each other or for the hub. Don't put the hub's private key on a worker, and don't let workers reach the hub's SSH port (on a Tailscale network, the access rules below enforce this).
What leaves the hub
sparehand run copies the current git checkout to the worker: every file git tracks plus untracked files git doesn't ignore. Ignored files stay home, which keeps node_modules, build output and most secrets off the workers.
**The .git folder stays home**, with its config (remote URLs can carry tokens), hooks, other branches, stash and reflogs. So that git works in the job, the worker gets the commit you have checked out and its history (as git bundles, only what it doesn't have yet, shared by the checkouts of one repository there), on the same branch name; the changes you haven't committed show there as not staged. That history is everything reachable from that commit, so a secret that was committed once and removed later still goes with it: clean it out of the history, or keep such a repository off workers you don't control. .git folders inside the checkout (submodules, nested repositories) stay home too: their files go, but git doesn't work inside them on the worker. Copies of .git made by older versions of Sparehand are deleted at the next job from that checkout. sparehand add and sparehand doctor warn when a remote URL of the checkout you run them in has a password or token in it.
.env files are the exception people ask about. By default they are not copied (SPAREHAND_SYNC_ENV=0), and an ignored .env* copied by an earlier run with sync on is deleted from the worker on the next run. Set SPAREHAND_SYNC_ENV=1 only for workers you trust as much as the hub, since a stolen .env usually means stolen credentials.
A git worktree is copied the same way: its own files, and git set up there as above.
What reaches outside services
Jobs, files and the dashboard stay between your own machines. Sparehand itself contacts only Lemon Squeezy (your licence), the product's website (a daily update check) and ollama.com (the daily list of new models, off with "aiLatest": false). Everything else happens because you set it up: Studio sends a production's scripts and pictures to the services you pick for it, Crew sends a task and its code to the agents you add, People invites go through Tailscale's API, notifications through the browsers' push services. The privacy policy names every one and what it gets.
Installing on a machine downloads from the publishers at that moment. sparehand ai install takes Ollama's latest GitHub release and checks it against the release's own SHA-256 list. sparehand studio install runs uv's install script, takes ComfyUI and most Python and npm packages at their newest version, and downloads models from Hugging Face over HTTPS without checksums of Sparehand's own; only the frame-interpolation nodes, Chatterbox and the Inter font are pinned to a version. Install Studio only on machines where you accept that trust in those publishers. What it downloads, with each licence, is listed in studio.md.
The on/off switch lives on the hub
sparehand disable NAME writes the name to ~/.config/sparehand/disabled on the hub. A disabled worker isn't contacted at all: no status probes, no jobs, no files. Because the switch is on the hub, a compromised worker can't switch itself back on. sparehand pause is different: its flag lives on the worker, so the person at that machine can pause it, but it's not a security control.
If a worker may be compromised (malware found, stolen and recovered, pirated software installed), sparehand disable it first, investigate, and only sparehand enable it when you're satisfied. If secrets were ever synced to it, rotate them.
Tunnels and shared folders
- **
sparehand forward** is an SSH tunnel whose local end listens on the hub's127.0.0.1only. The hub's key is installed (by pairing) withpermitopen="127.0.0.1:*",permitopen="localhost:*"instead ofno-port-forwarding, so the hub can tunnel to the worker's own services, never through it to other machines on its network. See forwarding.md. - **
sparehand files** shares one folder of the worker: SMB with encryption required, no guest access. On Windows the firewall lets in only the hub; on Linux Sparehand's share (and a Samba set up from scratch) answers only the hub; on a Mac, File Sharing answers the whole network, protected by that Mac's own login. Windows and Linux use a separatesparehand-filesaccount whose random password is kept in the hub's Keychain (Linux hubs: a private file). The Keychain item lets/usr/bin/securityread it without asking, which is how Sparehand connects without a prompt; any program running as you on the hub could read it the same way. System folders can't be shared. Administrator rights on the worker are used once to set it up, and the password is asked on the hub, never stored. See file-sharing.md. - GPU jobs are ordinary jobs with the same trust as any other (gpu.md).
- Cloud workers (cloud-gpu.md) are workers with the same trust. A Google Cloud VM is reached over Tailscale with no port open to the internet; Sparehand starts and stops it with
gcloudand your own Google login, keeps no keys, and runs only fixedgcloud compute instancescommands on the instance named in the cloud file (each value checked). The VM's idle timer powers it off from inside, without cloud credentials, and refuses to install on a machine that isn't a cloud VM. A Runpod pod, an AWS instance or an Azure VM is reached at the public address (and, for a pod, the port) the provider gives it, written into~/.ssh/configby the hub after each look; only the hub's key gets in (an AWS security group and an Azure network security group let only the hub's public address reach port 22), its SSH key pinned when the machine is made and checked on every connection. The providers' credentials (Runpod's API key, AWS's access key, Azure's service principal) live in the hub's Keychain, never in a file, go to each API in a request header, and the hub sends only machine, catalog and billing requests (lib/cloud/*.mjs). A pod has no credentials: the hub stops it. The dashboard's Start and Stop, and making a cloud machine, are owner-only actions like the others.
Worker mode
Worker mode closes apps on a worker so builds get its memory. It only ever asks apps to quit (like ⌘Q or clicking ✕), so apps can prompt to save; on Windows, game launchers that merely hide in the tray are closed firmly after a few seconds, browsers and chat apps never are. Apps are reopened as your normal (non-administrator) user. The default is SPAREHAND_WORKER_MODE=manual: nothing closes unless you ask.
GitHub Actions runners
sparehand runner add makes a worker run a repository's workflows (ci-runners.md). That lets whoever can change that repository's workflows run code on the worker, so:
- A public repository is refused unless you confirm it: there, anyone who can open a pull request can change a workflow.
- On Linux and WSL with Docker each job runs in a new container that is deleted afterwards, without the Docker socket, extra rights or your home folder. On a Mac, or without Docker, a job runs as the worker's user and can read what that user can.
- The registration token (from
ghon the hub, or pasted) is used once: it travels on stdin, never as an argument, and isn't kept on the hub or the worker. What stays on the worker is the credential GitHub issues to that one runner, in a folder only its user can open. - The runner reaches out to GitHub over HTTPS; nothing listens on the worker for it.
The app
The Sparehand app can pause, switch and stop things, so it's locked down the same way:
- It listens on 127.0.0.1 only, until you switch on This network at the hub. Until then nothing on your network can reach it directly.
- To use it from a phone or tablet anywhere, publish it with
tailscale serve(remote-access.md). Tailscale proxies only devices on your tailnet and adds the caller's identity; the app accepts only logins listed inallowedLogins(and, if you list any, device IPs inallowedIPs) or added on the People tab. A proxied request without a login (a tagged device, or Tailscale Funnel) is refused. - Those identity headers arrive on a loopback connection, which anything running on the hub can open. So when the
tailscalecommand is on the hub (on thePATH, or the Mac app's), the app asks ittailscale whoisfor the device the request came from and accepts the login only when Tailscale confirms it for that device (the answer is remembered for a minute per device; a device Tailscale doesn't know, or a whois that fails, is refused). Another reverse proxy proves itself instead with"proxySecret"inapp.json(a random string of at least 16 characters) sent as theX-Sparehand-Proxy-Secretheader; with a secret set and notailscalecommand, only requests carrying it get in. Without either, the headers are taken as they are: every account on the hub computer is then the owner, as is anything that can reach the hub's loopback with headers of its own. The dashboard's log says at start which of the three applies. - A request that doesn't come through the proxy, from the hub computer itself, is the owner's: anyone with an account on the hub can use the dashboard as you.
- Ask yourself the same question as for a worker before making anyone an owner: they can do all you can (remote-access.md).
- The licence file the Mac app writes for the
sparehandcommand (~/Library/Application Support/Sparehand/licence: allowed or not, until when, this Mac's ID, when it was written) is signed with an Ed25519 key the app keeps in its Keychain item; its public key sits beside the file (licence.pub) and the command pins the first one it sees in~/.config/sparehand/licence.pub. An edited file, one from another Mac, one dated in the future (the clock set back) or one signed with another key counts as no licence, andsparehand licencesays which. The command inside the app ignoresSPAREHAND_LICENCE_FILE; a checkout checks a licence only withSPAREHAND_LICENCE_ENFORCE=1(the tests), and=0turns the check off in a developer's build of the app. - A person added as a member pays with prepaid credits (credits.md) and may use only these routes (those marked
who: "member"in the route table,routesinapp/server.mjs); every other route answers 403 before it does anything, and the screen relay refuses them:- the machines' load, read-only (
/api/status), and their own account (/api/me); - the Local AI chat, with their own saved chats (
/api/ai,/api/ai/show,/api/ai/chat,/api/chats); - Studio, their own productions only (
/api/studioand its log, files and production pages): they send briefs, approve storyboards, steer, resume, stop and download them. Productions run on your machines and the API keys you set up; by default each request waits for your approval (Studio's Members' productions setting). They can't change Studio's settings, brand kits or machines, use files on the hub, or upload to YouTube (studio.md). Their Director is the text-only Gemini writer: a Crew agent, which works as your user on the hub and reads files there, directs a member's production only when members' agents are on ("membersAgents": trueinapp.json, off by default) or you chose it yourself when approving; - Crew, their own tasks only (
/api/crewand its branches, files and logs), in a workspace of their own on the hub (repositories they clone from anhttps://Git address or start empty), with agents billed per use or local models, never your subscriptions; nothing is pushed for them. Crew agents run as your user on the hub, so a member's agent can reach whatever you can there, not only their workspace: that is why members' Crew tasks are **off until you set"membersAgents": true** (a member's task is refused before anything is set up), and why you should turn them on only for people you would trust with your hub account (crew.md). The People tab says which way the switch is set. What a member is sent never includes an agent's sign-in folder or API address, your cost table (prices.tokens) or your markup: their own prices and credits only; - their apps, through an API key: it lets them reach the AI hub from anywhere it is published, with the same charges and no say over the models (the key is kept as a hash; a request without a key still works only from the hub computer itself). Cloud providers are yours alone unless
"aiCloudMembers": true; even then a provider without a free tier (paid, like xAI and Mistral, or anaiEndpointsserver marked"paid": true) stays closed to members unless"aiCloudMembersPaid": trueand it is priced inprices.tokens, so a member's answer is charged at the provider's prices times your markup and can't lose you money. Each member may send 30 requests a minute (aiMemberRpm), and each provider stops for the day once it has spentaiCloudUsdDaydollars ($10 by default), so one person can't run up a bill or use up a free tier for everyone (ai.md). - the routes themselves, from the route table (the dashboard's tests check this list against it):
GET /api/status,GET /api/me,GET /api/me/statement,POST /api/me,GET /api/ai,GET /api/chats,GET /api/chats/ID,POST /api/chats,GET /api/ai/show,POST /api/ai/chat,GET /api/crew,GET /api/crew/run/ID,GET /api/crew/branches,GET /api/crew/file,GET /api/crew/log,POST /api/crew,GET /api/studio,GET /api/studio/p/ID,GET /api/studio/log,GET /api/studio/file,POST /api/studio, and the page and its files (GEToutside/api/). A member the owner suspended keeps onlyGET /api/status,GET /api/me,GET /api/me/statementand the page.
- the machines' load, read-only (
- Local requests need a loopback
Hostheader, which defeats DNS-rebinding pages in a local browser. Actions need a custom header and same-origin requests, so another website can't trigger them, and WebSocket connections from other origins are refused. - Actions are an allowlist (pause, resume, enable, disable, worker mode, stop job) on workers from your own config; nothing a browser sends is ever run as a command.
- The screen relay connects only to the
SCREENaddresses in your Sparehand config. VNC passwords are typed in the browser and go straight to the VNC server; the app never stores them. - Add a machine runs only fixed
sparehandcommands (setup-info,hostkey,trust,add,lock,doctor) with strictly checked names, addresses and numbers. A new machine's SSH key is trusted only after you confirm that its fingerprint (or server check code) matches what the machine itself prints, so something else on the network can't stand in for it. A known machine that suddenly offers a different key is refused. The page shows the hub's public key, never a private one. If people other than you can open the app, turn this off with"addDevices": falseinapp.json. - Work items (only when
app.jsonhasworksources): tracker tokens stay in the Keychain (or environment), Sparehand only reads the trackers, and Start opens a Claude Code session in plan mode that waits for your approval before changing anything. Ticket text is treated as untrusted: shown as plain text, and passed to Claude as information rather than instructions. See work-items.md. - Pairing with a code opens a listener on the local network (port 8740,
pairPortinapp.json) only while a pairing runs: at most 15 minutes, one random 8-character code, closed after the machine is added or 20 wrong requests. The command you run on the new machine carries the join script's SHA-256, so a script changed on the way is refused before it runs. Nothing the new machine reports is trusted until you confirm that its screen shows the same check code (derived from its SSH host key); the hub then checks that key at the address the report came from, so a machine posing as the new one is refused. The hub's key is installed withfrom="<hub addresses>",permitopen="127.0.0.1:*",permitopen="localhost:*",no-agent-forwarding,no-X11-forwarding,no-pty. Thepermitopenoptions let the hub open tunnels (sparehand forward) only to that machine's own services, never through it to other machines on its network. The short "typing it by hand" command skips the checksum; use it only on a network you trust. - The Sessions tab reads Claude Code transcripts on the hub, and can run Claude Code sessions on the hub with your account: anyone who can use the app can do that. They never skip permission prompts (
bypassPermissionscan't be chosen), and each prompt waits for an answer in the app. Turn running them off with"liveSessions": false, or the whole tab with"sessions": false, inapp.jsonif others can use the app. See sessions.md. - Notifications (Web Push) are encrypted for each device and signed with the dashboard's own key (kept in
~/.local/state/sparehand/push, readable by you only); the dashboard only sends to the browsers' push services. Turn them off with"push": false.
This network
This network (remote-access.md) opens the dashboard to devices on the same home or office network that you paired at the hub, without Tailscale. It is off until you switch it on, at the hub itself; "lan": false in app.json takes it away. What it does and what it leaves open:
- One private address, TLS only. A second listener answers on the one address you chose, and only if it is private (10.x.x.x, 172.16–31.x.x, 192.168.x.x): never on every address, never on a public one, never on Tailscale's range, and a request for any other address is refused. Plain HTTP isn't answered there, because a device's token crosses the network with every request and anyone on the Wi-Fi could read it. The certificate is made on the hub (EC P-256, for that address, 397 days); its key stays in
~/.local/state/sparehand/lan, readable by you only. - The certificate is trusted by hand. No browser knows it, so a device shows a warning the first time and you choose to go on. From then on someone who only listens learns nothing, and someone who puts themselves in the middle makes the device warn again, since they can't show the hub's certificate: a warning on a device that was already paired means stop. During the first visit a device can't tell the hub's certificate from another one; compare the SHA-256 fingerprint the browser shows for the certificate with the one in Settings to be sure. Without that comparison the first visit trusts whoever answered.
- Pairing. A code is 20 random bytes, made at the hub, shown as a QR code and a link, good for five minutes and for one device; it sits in the link's fragment, so it is in no request line or log. The device that sends it gets a 6-digit check number, shown at the hub with what the device says it is and its address, and waits for Allow, which is given at the hub only. Then it gets its token (32 random bytes) once, as an
HttpOnly; Secure; SameSite=Strictcookie with the__Host-prefix (a browser takes it only from that very address over TLS); the hub keeps only the token's SHA-256 (lan-devices.json, readable by you only). A paired device is the owner, or the person from the People tab you gave it (a member stays a member; a person since removed is nobody). - On that listener a device without a token may use only the pairing routes, from the route table:
GET /pair, /pair.js,POST /api/lan/pair/requestandGET /api/lan/pair/wait. Every other route, the page and its files answer 403 (a browser gets a page without any data that says how to pair). Tailscale's identity headers and the proxy secret mean nothing there. The suite asks every route of the table as an unpaired device. - Checks on every request there. The
Hostheader must be the listener's own address and port (a page on another name pointing at it, DNS rebinding, is refused). What changes something needs the page's header, as everywhere, and anOriginthat is present and the listener's own. - Limits. Without an open code a pairing request is refused at once. Five wrong codes withdraw the open code, each address gets ten pairing requests a minute, one request waits at a time, and 20 devices can be paired.
- Only the network it was switched on for. The hub remembers the router it saw at switch-on (its hardware address, kept as a hash). When the hub's address goes away or the router is another one (a laptop hub on a café's network, where the same 192.168.x.x may be handed out) the listener closes within 15 seconds, and opens again at home. Where the router can't be read (neither macOS nor Linux), only the address is watched.
- Switching on, making a code and allowing a device are done at the hub itself: not through Tailscale and not from a paired device. Removing a device and switching the mode off, any owner may do. A removed device is refused from its next request; switching off closes the listener at once, and forget every device empties the list.
What a hostile device on the same network can do: find the port, see that a Sparehand dashboard answers there and read its certificate, load the pairing page, keep the port busy, and stand in the middle of a device's first visit as described above. What it can't: read or change anything without a token, guess a code or a token, learn a token by listening, use a paired browser from another site (SameSite, Origin), or reach the dashboard's loopback address or the AI hub. Someone who sees the QR code and scans it first shows up at the hub as a device you don't know, with a number your own device doesn't show: don't allow it, and make a new code.
What this mode doesn't have, compared with Tailscale: use from outside the network, a certificate browsers trust, and with that the service worker and notifications (Web Push). If the hub's address changes, devices pair again (their cookie belongs to the old address): give the hub a fixed address in the router. On a Mac with the firewall on, macOS asks once whether Node may accept incoming connections.
Reporting a problem
Please report security issues privately by email to support@sparehand.dev (see SECURITY.md).
Removing a worker
sparehand remove NAME (when the machine answers and nothing else uses it there; --clean insists) runs, as the hub's last commands there: file sharing off (the share, its account and firewall rule), Ollama's service and program removed (downloaded models stay), worker mode off, its GitHub Actions runners deregistered at GitHub (when gh is signed in on the hub) and their services stopped, ~/.sparehand deleted (run folders, dependency caches, markers), and the hub's line removed from ~/.ssh/authorized_keys. The SSH server, rsync and git stay, as does Remote Login on a Mac, and the worker's host key stays in the hub's ~/.ssh/known_hosts (remove it with ssh-keygen -R HOST if you like). On Windows the keep-alive task and the firewall rule need an administrator; the command prints them.