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-in — NOVA_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-sandboxis never even probed)nova—nova-sandboxonly; Docker is never consulted as a fallbackauto—nova-sandboxif this kernel can actually isolate (checked vianova-sandbox probeand 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.