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
  1. 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 answers 400, so the broker and the upstream always read the same path.
  2. Locate the capability. Either from the first path segment (/claude/…, /github/…) or, for clients that keep the real host name, from the Host header through a route. Unknown names get 404 with the hint skiff cap ls.
  3. Check the grant. The VM's grants file is read on every request, so a skiff grant or revoke in another terminal applies to the very next request of a running broker. A miss is a 403 with the exact command that would allow it.
  4. 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.
  5. 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:

skiff secret set claude-token
skiff grant agent1 claude github ssh-agent
skiff grants agent1
skiff revoke agent1 github

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

  1. Checks that every capability exists. A grant written as cap:something is refused: capabilities have no scopes.
  2. 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.
  3. 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.
  4. 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 guestToken permission
git clone, git fetchContents: read
git pushContents: read and write
gh pr create, gh pr list, gh pr viewPull 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:

ReferenceResolves to
keychain:skiff/NAMEthe macOS Keychain item with service skiff, account NAME. This is what skiff secret set NAME writes.
env:VARan environment variable of the skiff process on the Mac
cmd:shell commandthe stdout of sh -c on the Mac, e.g. a password manager CLI
file:/paththe content of a file on the Mac
skiff secret set github-token
echo "$TOKEN" | skiff secret set github-token

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 in proxy.headers and route headers. In any [guest] value it is an error, and skiff cap lint says so.
  • Literal references only. {{ secret "keychain:skiff/x" }} is fine; a reference computed from data such as .VM is 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" }} encodes user:secret in 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.

SessionBroker
skiff ssh agent1yes, for the session's lifetime
skiff exec agent1 -- cmdyes, for the command's lifetime
skiff tunnel agent1yes, until Ctrl-C or the VM stops; reconnects on its own
ssh agent1.skiff, VS Code Remote, an agent runnerno – 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

skiff tunnel agent1

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

GrantPrimitiveWhat the guest gets
claudeproxy → api.anthropic.comANTHROPIC_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.
githubproxy → github.com, route for api.github.coma 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-agentbuilt inssh -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://. Plain http:// is only accepted for localhost, 127.0.0.1 and ::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_headers removes more. allow_headers turns the filter into an allow list; Content-Type, Content-Length, Content-Encoding, Accept and Accept-Encoding always 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 the Host header, with its own upstream and headers. Route hosts must be public DNS names, never an IP or localhost, 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 POST carries one JSON-RPC message. Requests are answered with their response, notifications with 202. 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; GET answers 405.
  • 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.

VariableValue
SKIFF_VMthe VM name
SKIFF_CAPthe capability name
SKIFF_METHOD, SKIFF_PATH, SKIFF_QUERYthe request line, path without the /<cap> prefix
SKIFF_CONTENT_TYPEthe 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:

skiff cap init openai

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:

skiff cap lint openai
skiff secret set openai-token
skiff cap show openai --render --vm agent1
skiff grant agent1 openai

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:

FieldValue
{{ .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.env goes 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.files are written whole, with an optional octal mode.
  • guest.blocks are managed sections inside files other tools also write, such as /etc/gitconfig. Marker comments name the capability, so several capabilities can share one file and revoke removes exactly its own block. comment picks the marker syntax: # (default), ; or //.
  • guest.json_merge sets one dotted key in a JSON file, creating the file if needed. Use it for mcpServers.<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.

KeyMeaning
namerequired; lowercase letters, digits and dashes, starting with a letter, at most 32 characters. Must equal the file name without .toml.
descriptionshown by skiff cap ls
[proxy] upstreambase URL, https:// (or http:// on loopback)
[proxy.headers]header name → template; may use secret
proxy.strip_headersextra guest headers to remove
proxy.allow_headersif set, the only guest headers passed through (plus the always-kept five)
[[proxy.routes]] hosta 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, envthe stdio MCP server to run on the Mac
[exec] command, argsthe program to run per request on the Mac
[guest.env]variable → template; names [A-Z_][A-Z0-9_]*
[[guest.files]] path, mode, contentabsolute path, octal mode like 0644
[[guest.blocks]] path, comment, contentmanaged section; comment is #, ; or //
[[guest.json_merge]] path, key, valuedotted key; value is any TOML value, strings are templated
CommandWhat it does
skiff cap lsevery capability with kind, source and description, plus ssh-agent
skiff cap show NAMEthe 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 NAMEwrite 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

Sharing capabilities

A capability is one self-contained file, so it can be shared by URL:

skiff cap add https://example.com/gemini.toml

Before anything is installed, skiff prints the file and a plain-language summary of what it would do with your secrets and your Mac, then asks:

=> gemini proxies guest requests to https://generativelanguage.googleapis.com
   and sends the secret keychain:skiff/gemini-token in the X-Goog-Api-Key header
install as ~/.skiff/caps/gemini.toml? [y/N]
  • Only https:// URLs and redirects, at most 256 KiB.
  • A downloaded file may not replace a built-in capability: a remote claude.toml could otherwise point your stored token at any upstream. Overriding a built-in stays a manual copy.
  • It may not route a host another installed capability already routes.
  • Control characters and Unicode format characters are stripped from the preview, so nothing can hide lines from your review.
  • Installing does not grant anything. You still decide per VM with skiff grant.

Audit log and denials

tail -f ~/.skiff/audit.log
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 localhost or 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 mcp and exec. 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.