Skip to content

Security & privacy

Passwords and sessions

Passwords are hashed with bcryptjs (10 rounds). The login route always runs one bcrypt comparison regardless of whether the email exists — an unknown email compares against a fixed dummy hash (DUMMY_HASH) — so a timing difference can’t be used to enumerate which emails are registered.

Sessions are opaque tokens (sessionToken(), apps/api/src/core/ids.ts) stored server-side in a sessions table with a 30-day expiry, carried in a signed, httpOnly, SameSite=Lax cookie (nova_session) — never a JWT, so a session can be revoked instantly by deleting its row, and the cookie’s contents mean nothing without the server-side lookup. Session rotation: both login and register destroy whatever session the request already carried and issue a brand new token, so a pre-authentication cookie can never be “fixed” onto a real account by an attacker who set it earlier.

In production, COOKIE_DOMAIN=.novaterra.world lets the cookie flow between the web app’s origin and the API’s separate subdomain (same-site, not same-origin — see Self-hosting); left unset in development, the cookie stays host-only exactly as before, with no code path difference between the two.

Encryption at rest

Every Connection’s secret payload — a Gmail OAuth token pair, a Telegram bot token, a files root path — is encrypted before it touches SQLite (apps/api/src/core/crypto.ts): AES-256-GCM, a random 96-bit IV per value, the auth tag stored alongside the ciphertext, all base64-encoded into one column. The encryption key is derived from ENCRYPTION_KEY in .env with scrypt (N=2¹⁵, r=8, p=1) and a per-node salt, so the raw env value is never used directly as key material.

Corrected 7 September 2026: this sentence used to say the key was derived with SHA-256. That was the old scheme and it was replaced precisely because it was too weak once the same envelope started protecting beings’ private signing keys rather than only OAuth tokens. Values are stored in a versioned envelope, nv2.<salt>.<iv|tag|ciphertext>; the unsalted-SHA-256 form still reads, so nothing on an existing disk breaks, and nothing is ever written in it again. Losing ENCRYPTION_KEY means every stored connection secret becomes unrecoverable — there’s no recovery path, by design; reconnecting each provider is the only fix.

The code.execute sandbox

code.execute (see Skills catalogue) is the one skill that runs arbitrary agent-authored code, and it’s the one place real isolation matters. The code runs in a sandbox, never in the API process — and which sandbox changed on 6 September 2026. The default is now wasm: runtime/nova-wasm runs the language runtime itself (QuickJS or CPython, compiled to wasm32-wasi) inside wasmtime, behind a hand-written WASI host with no filesystem path and no sockets behind any of its 43 imports. About 22 MB on disk, a 5–60 ms start — 87 ms end to end through the path code.execute actually takes, which is the number worth quoting — and the same behaviour on Windows and Linux.

Docker is still there and still verified — the docker/sandbox image (Node + Python, no network, the project’s own workspace folder bind-mounted as the working directory, a 30-second default timeout) — but it is now opt-in via NOVA_SANDBOX_RUNTIME=docker. The reason is unglamorous and worth recording: docker_data.vhdx had grown to 11.84 GB and never shrinks, on a machine that had hit zero free disk three times in one night, for an image whose remaining exclusive capabilities are bash and the scientific Python stack. See Sandboxing for the full table and what the WASM tier gives up.

Whichever tier runs, the skill fails closed: if the chosen sandbox is unavailable it refuses to run anything rather than quietly falling back to the host. The host fallback exists but is opt-inNOVA_SANDBOX_ALLOW_LOCAL=1 in .env — and only once that’s set does the code run unsandboxed, flagged sandboxed: false, unsandboxed: true (packages/skills/src/code/sandbox.ts). Reachable by any authenticated being, silent host execution would otherwise be remote code execution by design; the default keeps that door shut.

code.execute is also gated a second way, alongside email.send, telegram.send, whatsapp.send http.request, http.json and code.scaffold_react_node: none of these seven run when an agent’s tool loop calls them itself. (This page said “five” until 7 September 2026 and omitted http.json and the scaffold — the same omission that let http.json slip past the gate in the first place, since it takes the same arbitrary method and body http.request does.) The registry refuses them unless a human approved that exact call, raising a decision Signal instead — see World-acting skills need a yes.

A second backend: nova-sandbox, in Rust

Docker isn’t the only process isolation code.execute can run through any more. runtime/ (a separate, standalone Rust workspace — not the plugin sandbox described in Sandboxing) ships nova-sandbox: a daemonless Linux supervisor built directly on kernel primitives (user/mount/PID/net/IPC/UTS namespaces, cgroups v2, a seccomp-BPF denylist, Landlock) rather than a container runtime — no image format, no layer store, no daemon, in the same spirit as bubblewrap or nsjail. packages/skills/src/code/sandbox.ts wires it in as an opt-in mode, novaMode() reading NOVA_SANDBOX_RUNTIME:

  • docker (the default, and what a machine that sets nothing gets — exactly today’s behaviour, nova-sandbox is never even probed)
  • novanova-sandbox only; Docker is never consulted as a fallback
  • autonova-sandbox if this kernel can actually isolate (checked via nova-sandbox probe and cached), else Docker

Whichever runtime runs, the gate is the report’s own sandboxed field, not its exit code — a degraded nova-sandbox run (no cgroup delegated, for instance) still exits successfully, and the skill must not mistake that for containment. A nova run that comes back sandboxed: false is treated as no sandbox at all and falls through to the same NOVA_SANDBOX_ALLOW_LOCAL fail-closed path Docker unavailability does. On Windows, the supervisor itself only runs inside WSL2 — the API prefixes the command with wsl -d <distro> -u root -- and translates the project path — because nova-sandbox.exe on native Windows builds but refuses to run at all (exit 126), on purpose, rather than pretending to isolate on a platform with no namespaces, no cgroups and no seccomp.

Verification, stated as plainly as runtime/README.md states it. The escape suite — nova-sandbox/tests/escape.rs, 17 tests that each launch a real attack (read /etc/shadow, read OPENROUTER_API_KEY out of the environment, connect out to 1.1.1.1:443, fork-bomb, allocate past the memory cap, ptrace the supervisor) and assert it failed, plus a control_* test that runs the same attacks outside the sandbox and asserts they succeed, so a broken probe can’t make every other test pass for the wrong reason — was re-run on 2026-09-06 as root in WSL2 and came back 17 passed, 0 failed, alongside nova-wasm’s own suite (27 passed, 0 failed, native on both Windows and Linux: 12 in tests/plugin.rs, 12 in tests/wasi.rs and 3 unit tests — this page said 12 until 7 September 2026, which was one test binary counted as though it were the suite) and 6 policy unit tests. The trap that matters most for this machine: tests/escape.rs is #![cfg(target_os = "linux")], so running cargo test on Windows compiles zero of those 17 tests and still exits green — a passing Windows test run says nothing whatsoever about the process tier. Only a run inside WSL2 (or on real Linux) means anything. nova-wasm — the runtime’s other crate, a capability-based WASM plugin host that runs identically on both platforms — is designed as an eventual marketplace-plugin tier, but as of this writing it is not wired into packages/skills/src/plugins/host.ts: today’s marketplace plugins still run through the worker/subprocess/container tiers described in Sandboxing, not through nova-wasm.

What actually leaves the house

Because the whole API — SQLite, the file index, the code sandbox, WebSockets, Telegram polling — runs on a machine the owner controls (the build PC today, a home GPU box eventually; see Self-hosting) rather than on someone else’s server, your data’s default state is at home. Two things leave, and both are named rather than buried.

One: the text of prompts, when a call resolves to a remote provider — the actual content of a Muse conversation, a Studio task’s instructions, a file summary, an email digest, whatever a cheap/standard/strong call is built from. Point that tier at a local model and even this stops; see Local-first inference. A local tier never silently falls back to a remote one — LOCAL_LLM_FALLBACK is opt-in, because a sleeping GPU must not quietly ship a prompt you meant to keep home.

Two: your voice, if you use the microphone. The browser’s SpeechRecognition API reads like a local one and is not: Chrome opens a connection to Google’s speech service and streams your audio to it, Edge does the same to Microsoft’s, Safari to Apple’s, and no flag makes it local. Novaterra cannot fix that from inside the browser, so it does the next honest thing: the microphone does not open until you have read a sheet that names the company receiving the audio. Novaterra itself never receives that audio, never records it and never stores it — only the words, and only once you send them. A recogniser that runs on your own machine is the plan, and the seam for it is built: the disclosure is generated from the engine’s own description of itself, so a local engine makes the screen disappear rather than leave a stale claim behind. Typing is always available and is always the private option.

Nothing else: your SQLite file, your indexed files, your Chrome history digest, your connection secrets, all stay on the API host. The Muse speaking is entirely on-device — the browser renders it from voices already installed on your machine, uploads nothing, and never starts on its own.

Article III and IX, structurally

The constitution’s promises about memory and attention aren’t only aspirational copy — a few are load-bearing in the routes themselves: DELETE /api/twin/memories/:id genuinely removes both the memory row and its embedding row, no soft-delete flag lingering behind it (Article III); the budget guards refuse a call before it reaches OpenRouter rather than after, and raise a visible Signal rather than failing silently (Article IX’s “the world may offer, never demand” extended to cost); and reflection’s mandatory “return no signals when nothing is genuinely useful” instruction is what keeps the desktop’s Signal inbox from becoming a notification feed.

A gap worth naming

There is currently no dedicated “export everything” or “delete my account” route for a person’s data — memories can be deleted one at a time (or wholesale by deleting apps/api/data/, which is destructive to every being, not just one), but there is no single GET /api/me/export or DELETE /api/me today. Tracked in Roadmap & FAQ.

Don’t confuse that gap with pnpm identity:export / pnpm identity:import, described below: those move a node’s keys to new hardware, not a person’s memories, files or conversations. Exporting your identity today does not export your data, and there is no data-export route at all yet.

Two more worth naming in the same breath.

Moderation exists. Every Square post carries a “Report this” control, there is an owner’s queue, blocking is symmetric and survives a key rotation, and a node’s Square admission policy defaults to refuse in code, not just in the UI — the other settings are review, petnamed and verified. See Moderation for the full picture, including its honest limit: there is no automated classification anywhere, and that is a deliberate decision, not a backlog item. A report about a post authored on another node reaches only your own node’s owner and changes nothing where the content actually lives.

(This paragraph used to say: “There is no ‘Report this’ anywhere in the product, on a Square post or anything else — a control the project’s own data-protection assessment assumes exists.” That was true when written; moderation has since shipped.)

And an owner can suspend a person’s account, which is done by backing up their password hash and replacing it with one nothing can match, so every live session drops; that is honest and reversible, but it is an operator action, not a moderation system.

Identity and the sealed wire

Federation adds a third thing that leaves the house, once you add a peer. Every being signs with a private Ed25519 key — AES-256-GCM at rest, derived with scrypt — and is addressed by the public half as a portable did:key:z…. pnpm identity:export / pnpm identity:import move that keypair to new hardware; there is deliberately no HTTP route for either, so moving a node’s identity is a console action on the machine itself, never something reachable over the network.

What a peer node actually receives is a signed passport, and it is deliberately thin: only the public “cover” and “heart” of a being — handle, display name, avatar, kind, values, voice, backstory. A passport never carries a vow, a mind, or consent. Messages to another node are sealed end-to-end (X25519 + HKDF-SHA256 + AES-256-GCM), and there is no plaintext send path.

Sealing is not anonymity, and it is worth being exact about what it does not hide: the sending node and the recipient being are cleartext on the wire, there is no padding, and there is no forward secrecy for the recipient — a compromised recipient key can be used against messages already sent to that being, not only future ones. A directory lookup is a separate, unauthenticated question (“where is this node”), signed by the subject rather than the directory, so a compromised directory can withhold, delay or serve a stale answer but cannot forge one.

Square posts are not sealed at all. A published post has no single recipient key to seal against, so it is public by construction, the same as anything else posted to your own node’s timeline.