Skip to content

Security & trust

Subspace runs powerful things (agents with real tools, terminals, coding-agent dispatch) on a machine that also holds your mail, your calendar, and your knowledge. Its security model is built around three ideas working together: a plane split that separates trust levels, a capability broker that decides which secrets any run may touch, and layered approval gates that keep a human in the loop on anything outbound or irreversible. Multi-user access control (page ACLs) and per-tool approvals (tools and approvals) sit on top of this foundation.

Work is organized into three planes by how much authority it is trusted with:

Trusted plane

The server process itself: it holds the encrypted secrets, runs the command model, and owns the database. Highest trust.

Worker plane

The agent engine can optionally run as its own process with a restricted environment, obtaining capabilities through the broker rather than holding ambient credentials.

Sandbox plane

Custom functions run in a QuickJS-WASM sandbox with a curated API and zero ambient authority. The strongest form of untrusted execution.

The --role entrypoint flag selects the split (web, worker, or all, defaulting to all for a single-user local install). Running the agent engine under --role=worker gives it a secret-free environment and routes every credential need through the broker, so tool code never changes, by construction.

Credentials are never scattered through the process environment. They live encrypted in one place and are handed out per run:

Encrypted at rest

Secrets live in ops.secrets as libsodium secretbox rows (a v1: prefix plus base64 of nonce and ciphertext). The master key lives in <home>/secret.key at mode 0600, minted on first use, and is deliberately not an environment variable. On a desktop install it can upgrade to the OS keychain. Everything else references a secret by name only (Authorization: github.mcp_token), never by value.

Granted per run

A tool declares what it needs with requires: [] (its reach: mail.send, web.fetch, wandb.api, and so on). The broker decrypts a secret on demand at call time, checks it against the run’s grant set, and writes an audit row for every acquire. A missing grant is a tool-result denial, never an approval prompt. By default a run’s grant set is the union of its allowlisted tools’ requires, so only an explicit caps: on the agent definition narrows reach further.

At boot, any legacy plaintext secret rows are swept and rewritten as encrypted rows, and environment-provided credentials are imported once into ops.secrets rather than read live. LLM and speech-to-text keys never leave the trusted server process.

Not all data carries the same trust, and admin omniscience has deliberate exceptions:

Domain Rule
The OKF disk mirror Operator trust. Filesystem access to the OKF dump exports the whole graph, so it is treated as operator-level access.
Comms (mail, IM, calendar) Personal. Rows are scoped strictly to their owning user, with no admin bypass, the one exception to admin visibility.
PTY, os.exec, code.* Admin-only. A shell on the host is the highest execution tier; coding-agent dispatch rides the same rule.

Agents act as their run’s owner: a tool’s authorize check runs against the owner’s live page ACLs before any gate, and an unreadable page is reported as “no such page” rather than probed. A permission denial is always a tool result, never an approval prompt.

On top of permissions sits the human layer. Any outbound or irreversible tool (send mail or IM, calendar writes, LinkedIn actions, os.exec) pauses the run on an approval card. Gate composition is explicit:

  • The agent’s approval policy (always gates everything, never gates nothing, auto defers to the tool’s own needsApproval) is composed with a per-tool trust floor: a tool marked alwaysGate (like os.exec) is gated no matter what the policy says.
  • The order is fixed: authorize (permissions) first, then requires-approval (gates). A denial from the permission check never turns into an approval prompt.
  • An approval card can carry a trusted preview derived from the live system (a parsed plugin manifest, say) rather than model-authored prose, and standing consent can lift a gate only against a version-pinned, human-granted object (an approved launch-template hash), never a policy knob and never model-controlled input.

See tools and approvals for the authoring side of gates.

Between fully sandboxed functions and admin-only raw exec sits a middle tier for running real code with contained blast radius:

  1. Sandboxed functions

    Custom functions run in QuickJS-WASM with a curated, rate-limited host API and quotas. No ambient authority; output is a SubUI JSON tree, never raw HTML, so the sandbox boundary holds.

  2. Sandboxed bash (Docker)

    Launchers and coding tasks run in a Docker container adapter rather than on the host. The docker lane is gated behind SUBSPACE_CONTAINERS=1 (or SUBSPACE_DOCKER_BIN), always approval-gated, and cannot touch the host filesystem the way raw exec can.

  3. Admin-only exec

    The bash lane on the host (SUBSPACE_OS_EXEC=1), terminals, and code cells are full power, explicitly user-invoked, and admin-only.

Imported content is tiered too, because it is the widest injection surface. Inbound HTML mail renders only sanitized, in a non-same-origin sandboxed iframe. Extension captures store extracted Markdown plus a screenshot, never live HTML. Sandbox output reaches React as a JSON tree, never dangerouslySetInnerHTML, and the SPA ships a strict CSP. One crafted email or captured page cannot become script with command authority.

In a multi-user instance, isolation is enforced at the data layer, not by UI hiding, and it is audited both ways:

  • Every request resolves to a principal (session cookie, then device token, then WebSocket token), and an instance with no users behaves exactly like the single-user app until the first login claims it.
  • An unauthorized read returns NOT_FOUND, so page existence is never probed.
  • Search filters both arms before ranking: the lexical arm filters in-query, and the vector arm over-fetches and drops unreadable references before fusion, so a private page can never leak through a search score.
  • Paired devices hold scoped bearer tokens; revocation kills the next request and terminates live sockets. The server binds localhost and the tailnet only, never a public interface, and Postgres binds a unix socket with no TCP listener at all.