Grants and capabilities
A VM is cut off from your Mac on purpose. When the agent inside needs GitHub, Claude Code or an MCP server that only runs on the Mac, skiff opens one controlled hole: a broker that hands out connections, never secrets.
The model in one minute
Four words carry the whole design. Keep them apart and everything else follows.
capability
A TOML file that says how something reaches the guest: what the broker does on the Mac and what the guest needs to use it. Ships for claude and github; yours live in ~/.skiff/caps.
grant
A line in a per-VM file that says whether a VM may use a capability, by name, such as github. Default is deny. How far it reaches is up to the credential.
broker
A process on the Mac, one per VM, that enforces the grants. It exists only while a skiff ssh, exec or tunnel session for that VM is open.
secret
A credential in the macOS Keychain (or env, file, command) that the broker injects into upstream requests. It never enters the guest; the guest holds a placeholder.
So a capability file never decides who may use it, and a grant never contains a credential. Policy (~/.skiff/vms/<vm>/grants), enforcement (the broker) and secrets (the Keychain) all stay on the Mac, next to each other. Nothing in the guest is worth stealing.
What happens on a request
Say Claude Code inside the VM agent1 sends a message. It was configured by the claude capability to talk to http://127.0.0.1:7777/claude:
VM agent1 your Mac claude ──► 127.0.0.1:7777/claude/v1/messages Authorization: Bearer sk-ant-oat01-<placeholder> │ │ ssh -R (skiff ssh / exec / tunnel) ▼ ~/.skiff/run/agent1.sock ──► broker(agent1) 1 canonical path? 2 which capability? 3 granted? (grants file, re-read) 4 drop guest credentials, inject the real token 5 write audit line │ ▼ https://api.anthropic.com/v1/messages
- Path check. The path must have exactly one reading: no percent-encoding, no backslashes, no
.or..segments, no empty segments, nothing that changes under cleaning. Otherwise the broker answers400, so the broker and the upstream always read the same path. - Locate the capability. Either from the first path segment (
/claude/…,/github/…) or, for clients that keep the real host name, from theHostheader through a route. Unknown names get404with the hintskiff cap ls. - Check the grant. The VM's grants file is read on every request, so a
skiff grantorrevokein another terminal applies to the very next request of a running broker. A miss is a403with the exact command that would allow it. - Hand over the connection. For a proxy, credential headers the guest sent are always removed, then the header templates are rendered with the real secret and the request goes upstream over TLS. Responses stream back as they arrive.
- Audit. One line per request in
~/.skiff/audit.log: time, VM, capability, decision, method, path, status.
Grant, inspect, revoke
Store the credential once, then grant capabilities per VM. Several grants fit in one command:
Or grant while creating the VM: skiff new agent1 --grant claude --grant github. skiff ls shows the grants of every VM in the GRANTS column.
What grant does
- Checks that every capability exists. A grant written as
cap:somethingis refused: capabilities have no scopes. - Takes a per-VM lock, so two grants or a grant and a revoke running at the same time cannot lose each other's change.
- Renders the guest configuration of all granted capabilities and applies it over SSH: environment variables, files, managed blocks, JSON merges and the broker socket relay.
- Only then saves the grant. A provisioning failure never leaves a silent allow behind. The VM therefore has to be running when you grant.
revoke runs the other way round: it removes the grant first, then removes exactly the configuration that capability had added to the guest (env lines, files, its managed block in a shared file such as /etc/gitconfig). Settings you made yourself in those files stay.
Where grants live
One directory per VM under ~/.skiff/vms/<vm>/ on the Mac, with a grants file (one grant per line) and the VM's placeholder token. skiff rm deletes both. A new VM that reuses the name starts with no grants at all; skiff gc removes state left behind for VMs that no longer exist.
What a grant reaches
A grant is just the name of a capability: claude, github, chrome-devtools. There are no scopes. The broker does not try to read request paths to decide which repository or project a call touches; the credential behind the capability decides that, and the upstream enforces it.
That is the more reliable boundary. An API knows exactly which resources a token may reach, including through GraphQL, search and endpoints that act beyond one repository, while a proxy that parses paths always lags behind the API it guards.
Grant a capability, then make the secret behind it as narrow as the task: a fine-grained GitHub token for the repositories the agent may touch, a per-project API key, a key with a spending limit.
GitHub in practice
Create a fine-grained token on github.com and select only the repositories the agent should work on. Give it the permissions the agent needs and no more:
| In the guest | Token permission |
|---|---|
git clone, git fetch | Contents: read |
git push | Contents: read and write |
gh pr create, gh pr list, gh pr view | Pull requests: read and write |
gh issue … | Issues: read and write |
With skiff grant agent1 github, git and gh in the guest then reach exactly those repositories with exactly those permissions, whichever endpoint they call. The github capability uses one token for every VM it is granted to, so every such VM reaches the same repositories; when a task needs other repositories, change the token's repository selection on github.com.
Grants from earlier versions
Grants files written while skiff still had scopes (github:acme/app, github:*) keep working: the scope is dropped when the file is read, and the next change writes the plain name. A new grant with a scope is refused with a hint to limit the credential instead.
Secrets
Capabilities refer to credentials by reference, never by value. Four schemes exist:
| Reference | Resolves to |
|---|---|
keychain:skiff/NAME | the macOS Keychain item with service skiff, account NAME. This is what skiff secret set NAME writes. |
env:VAR | an environment variable of the skiff process on the Mac |
cmd:shell command | the stdout of sh -c on the Mac, e.g. a password manager CLI |
file:/path | the content of a file on the Mac |
secret set prompts without echo on a terminal and reads the first line of stdin otherwise. The value never appears in a process argument list. Empty and multi-line values are refused. skiff secret rm NAME deletes it again.
- Resolved on the Mac, at request time. Values are cached for 60 seconds, so a rotated token is picked up without a Keychain call per request. Failed lookups are cached for 5 seconds, so a burst of guest requests does not become a burst of Keychain prompts.
- Broker side only.
{{ secret "…" }}works inproxy.headersand route headers. In any[guest]value it is an error, andskiff cap lintsays so. - Literal references only.
{{ secret "keychain:skiff/x" }}is fine; a reference computed from data such as.VMis rejected. That way the secrets a file uses can be listed before you install it, and cannot change at runtime. - Basic auth is a pipe away:
{{ secret "keychain:skiff/github-token" | basicauth "x-access-token" }}encodesuser:secretin base64.
When the broker is up
The broker lives exactly as long as a session that carries it. Close the terminal and the hole closes with it.
| Session | Broker |
|---|---|
skiff ssh agent1 | yes, for the session's lifetime |
skiff exec agent1 -- cmd | yes, for the command's lifetime |
skiff tunnel agent1 | yes, until Ctrl-C or the VM stops; reconnects on its own |
ssh agent1.skiff, VS Code Remote, an agent runner | no – run skiff tunnel agent1 alongside |
While it runs, the broker listens on ~/.skiff/run/<vm>.sock (mode 0600) and ssh forwards that socket to 127.0.0.1:7777 in the guest. ssh runs with ExitOnForwardFailure, so a process squatting on port 7777 in the guest makes the session fail instead of silently running without a tunnel.
For clients that only speak HTTP over a Unix socket (such as gh), the guest additionally gets /run/skiff/broker.sock: a systemd socket that relays to port 7777. It exists while any capability is granted; without an open session its connections are refused.
Several sessions, one broker
Each VM has exactly one broker, owned by one process through a lock. A second skiff ssh notices the owner and uses its broker instead of forwarding a second time. If the owner ends first, a remaining session takes over within about two seconds. A forward is only ever made by the owner, so none can outlive the broker it points to.
skiff tunnel
Holds only the forward, no shell. It detects a dead connection within about 45 seconds, reconnects with a backoff from 5 seconds up to one minute, logs tunnel=open and tunnel=closed to the audit log, and ends when the VM stops. It refuses to start for a VM without broker grants.
The ssh-agent grant
ssh-agent is built in and has no capability file. It adds ssh -A to skiff ssh and exec, so the guest can sign with your Mac's SSH keys without holding them. Add keys with ssh-add -c to confirm every single signature on the Mac.
Built-in capabilities
| Grant | Primitive | What the guest gets |
|---|---|---|
claude | proxy → api.anthropic.com | ANTHROPIC_BASE_URL pointing at the broker, CLAUDE_CODE_OAUTH_TOKEN set to a placeholder, onboarding marked done in /root/.claude.json. Secret: claude-token from claude setup-token. |
github | proxy → github.com, route for api.github.com | a managed block in /etc/gitconfig that rewrites https://github.com/ and git@github.com: to the broker; http_unix_socket and a placeholder token in gh's hosts.yml, which gh reads on every call. Secret: github-token. |
ssh-agent | built in | ssh -A agent forwarding |
See the exact files with skiff cap show claude, and what one specific VM receives with skiff cap show github --render --vm agent1. A file of the same name in ~/.skiff/caps replaces a built-in one.
Claude Code runs unmodified and sends exactly the headers of a normal subscription login; the broker only swaps the token. Usage counts against your own plan. Anthropic's terms allow signing in to the unmodified binary with your own subscription, including in sandboxes you run, but not routing other people's requests through your account. Keep the broker personal.
GitHub downloads that redirect to other hosts (gh release download) do not work through the broker, and percent-encoded paths are refused.
The three primitives
The broker itself knows nothing about Claude, GitHub or any other tool. It has three primitives; every tool-specific detail lives in a capability file. Each file uses exactly one.
[proxy] – inject credentials, forward the request
Requests to /<cap>/rest go to upstream + /rest. Use it for any HTTP API that authenticates with a header.
- The upstream must be
https://. Plainhttp://is only accepted forlocalhost,127.0.0.1and::1, because the broker attaches credentials. - The guest's own credential headers are always removed:
Authorization,Proxy-Authorization,Cookie,X-Api-Key,Api-Key,X-Auth-Token,Private-Token,X-Github-Token,X-Goog-Api-Key,X-Amz-Security-Token,X-Access-Token. A guest can neither smuggle its own credential upstream nor override the injected one. strip_headersremoves more.allow_headersturns the filter into an allow list;Content-Type,Content-Length,Content-Encoding,AcceptandAccept-Encodingalways survive.[[proxy.routes]]serve clients that cannot take a base URL but can be pointed at a Unix socket, such as gh through.BrokerSocket: they keep their real host name and path, and the broker picks the route by theHostheader, with its own upstream and headers. Route hosts must be public DNS names, never an IP orlocalhost, and each host may be routed by one capability only, so no capability can catch the requests of another.- Responses are flushed as they arrive, so server-sent events and streaming completions work.
- If the upstream is unreachable or a secret cannot be resolved, the guest gets a generic
502; the details are in the audit log on the Mac.
[mcp] – an MCP server on the Mac, as HTTP in the guest
Runs a stdio MCP server on your Mac and serves it to the guest as a Streamable HTTP endpoint at /<cap>. Use it for tools that need the Mac: your browser, your local apps, your files.
- The server starts lazily on the first request and stops when the broker closes.
- Every
POSTcarries one JSON-RPC message. Requests are answered with their response, notifications with202. Request IDs are rewritten on the way in and back on the way out, so several clients in the guest cannot collide. - Server-initiated requests (sampling, elicitation) are not supported and are answered with a JSON-RPC error;
GETanswers405. - A request may take up to five minutes. The server's stderr goes to the audit log.
An MCP capability runs code on your Mac, outside the VM. Pin versions (chrome-devtools-mcp@1.10.1, not @latest) and bump them deliberately.
[exec] – run a program per request
Runs a program on the Mac for every request at /<cap>/…: the request body goes to stdin, stdout becomes the response body. Use it when the guest should get an answer computed with a credential, never the credential itself.
| Variable | Value |
|---|---|
SKIFF_VM | the VM name |
SKIFF_CAP | the capability name |
SKIFF_METHOD, SKIFF_PATH, SKIFF_QUERY | the request line, path without the /<cap> prefix |
SKIFF_CONTENT_TYPE | the request's Content-Type |
Limits: 8 MiB in, 8 MiB out, two minutes per call, four concurrent runs per capability. A non-zero exit becomes a generic 502 in the guest, with the last stderr line in the audit log. Whatever the program prints lands in the VM, so never print a token.
Writing a capability
Say an agent in the guest uses an OpenAI-compatible SDK that honours OPENAI_BASE_URL. Start from the commented skeleton:
Edit ~/.skiff/caps/openai.toml down to what you need:
~/.skiff/caps/openai.toml
name = "openai"
description = "OpenAI API via the broker, key stays on the Mac"
[proxy]
upstream = "https://api.openai.com"
[proxy.headers]
Authorization = "Bearer {{ secret \"keychain:skiff/openai-token\" }}"
[guest.env]
OPENAI_BASE_URL = "{{ .BrokerURL }}/openai/v1"
# SDKs refuse to start without a key; the broker replaces it anyway.
OPENAI_API_KEY = "{{ .PseudoToken }}"
Then lint it, store the key, check what the guest will receive and grant it:
Two more complete examples ship in the repository: chrome-devtools.toml bridges the Chrome DevTools MCP server from the Mac, gh-api.toml answers read-only GitHub API calls with gh on the Mac through exec.
Template data
Every guest string and every header is a Go template with these fields:
| Field | Value |
|---|---|
{{ .VM }} | the VM name |
{{ .BrokerURL }} | http://127.0.0.1:7777, the broker as seen from the guest |
{{ .BrokerSocket }} | /run/skiff/broker.sock, for clients that only take a Unix socket |
{{ .PseudoToken }} | a random per-VM placeholder for clients that insist on a token. The broker never checks it: the tunnel is the identity. |
{{ secret "ref" }} | broker side only, see Secrets |
… | basicauth "user" | base64 of user:value for HTTP Basic |
Unknown fields are errors, so a typo such as .BrokerUrl is caught by lint instead of rendering empty.
How the guest is configured
guest.envgoes into/etc/environment, which pam_env reads for every SSH session, interactive or not. Processes that were already running, such as a daemon, only see it after a restart; if a tool can read its settings from a file instead, prefer a file or block.guest.filesare written whole, with an optional octalmode.guest.blocksare managed sections inside files other tools also write, such as/etc/gitconfig. Marker comments name the capability, so several capabilities can share one file andrevokeremoves exactly its own block.commentpicks the marker syntax:#(default),;or//.guest.json_mergesets one dotted key in a JSON file, creating the file if needed. Use it formcpServers.<name>in/root/.claude.json.
Everything is rendered on the Mac and applied over SSH with plain POSIX tools, so the guest image needs nothing skiff-specific. Two granted capabilities may not set the same variable, file or JSON key; grant refuses the combination and names both.
Capability file reference
One file per capability in ~/.skiff/caps/<name>.toml. Unknown keys are errors, so typos surface at lint.
| Key | Meaning |
|---|---|
name | required; lowercase letters, digits and dashes, starting with a letter, at most 32 characters. Must equal the file name without .toml. |
description | shown by skiff cap ls |
[proxy] upstream | base URL, https:// (or http:// on loopback) |
[proxy.headers] | header name → template; may use secret |
proxy.strip_headers | extra guest headers to remove |
proxy.allow_headers | if set, the only guest headers passed through (plus the always-kept five) |
[[proxy.routes]] host | a public DNS name with a dot; no IPs, no localhost. Each host may be routed by one capability only. |
routes.upstream, [proxy.routes.headers] | like the proxy's own; strip_headers/allow_headers apply as well |
[mcp] command, args, env | the stdio MCP server to run on the Mac |
[exec] command, args | the program to run per request on the Mac |
[guest.env] | variable → template; names [A-Z_][A-Z0-9_]* |
[[guest.files]] path, mode, content | absolute path, octal mode like 0644 |
[[guest.blocks]] path, comment, content | managed section; comment is #, ; or // |
[[guest.json_merge]] path, key, value | dotted key; value is any TOML value, strings are templated |
| Command | What it does |
|---|---|
skiff cap ls | every capability with kind, source and description, plus ssh-agent |
skiff cap show NAME | the TOML source, embedded or yours |
skiff cap show NAME --render [--vm VM] | the env, files, blocks and JSON merges a guest would receive |
skiff cap init NAME | write a commented skeleton to ~/.skiff/caps |
skiff cap lint [NAME] | validate one file, or all of yours together |
skiff cap add URL [-y] | download, review and install, see below |
Audit log and denials
2026-10-08T09:10:02Z vm=agent1 tunnel=open 2026-10-08T09:12:44Z vm=agent1 cap=github allow GET "/github/acme/app/info/refs" 200 2026-10-08T09:12:51Z vm=agent1 cap=claude allow POST "/claude/v1/messages" 200 2026-10-08T09:13:05Z vm=agent2 cap=github denied GET "/github/acme/app/info/refs" 403
The decision is one of allow, denied, unknown cap, no cap, unsafe path, grants or setup. Host-side diagnostics, such as a missing Keychain item or an MCP server's stderr, appear as indented lines underneath. The guest only ever sees a generic message, so paths, commands and item names stay on the Mac.
A denied request tells the guest exactly what would allow it, which also tells an agent what to ask you for:
$ curl -s 127.0.0.1:7777/github/acme/app/info/refs
{"error":"github is not granted to agent2","hint":"skiff grant agent2 github"}
Security model
What the design guarantees
- No credential in the guest. The guest holds a placeholder that is worth nothing outside the tunnel. A copied disk, a leaked environment, a malicious dependency: none of them yields a token.
- The connection is the identity. Each VM's broker listens on its own socket on the Mac, and the only way in from a guest is that VM's own ssh forward. One VM can never present itself as another.
- Default deny, re-checked per request. A revoke applies to the next request, not the next session.
- Time-boxed. No session, no broker. The hole is open while you are working with the VM, not while it merely runs.
- Reviewable. The secrets a capability can send are fixed in its file and shown before installation; upstreams must use TLS; route hosts cannot be
localhostor an IP and cannot catch another capability's traffic.
What it does not do
- Limit the upstream. With a capability granted, the guest can do whatever its token allows. The token is the boundary: use fine-grained GitHub tokens and per-project API keys.
- Tell processes apart inside the guest. While the tunnel is open, any process in the VM can use what the VM was granted. One VM per task keeps that blast radius small.
- Sandbox
mcpandexec. Those programs run on your Mac with your user's rights. Read them before you install them and pin their versions. - Hide what the agent saves itself. If an agent logs in inside the VM on its own, that credential is on the VM's disk like any other file.