Security & privacy

Your brain goes on a server you own. Some of it is protected by construction, some of it is protected by you, and one part is not protected at all. This page says which is which.

The tier wall is a directory

A visibility tier is not a filter applied to your notes when someone asks for them. It is a separate copy of your brain, built when the server indexes it.

Inside a running server there are three:

$ ls /data/brain-views
agents-only
private
public

Each copy carries a manifest saying what went into it. From one brain, at one commit:

public       entities=2   files=3    head=2414edafb73f
agents-only  entities=7   files=10   head=2414edafb73f
private      entities=7   files=10   head=2414edafb73f

Seven files exist in the private copy and not in the public one. Ask the public copy for one of them and there is nothing there to refuse:

$ cat /data/brain-views/public/identity/beliefs.md
cat: /data/brain-views/public/identity/beliefs.md: No such file or directory

That file is 32 bytes at the private tier. At public it does not exist. This is why a low tier cannot tell a real note from an invented one: Reading shows you that from the outside, and this is the reason underneath it.

One honest note about those numbers. In this brain agents-only and private hold the same 7 entities, because nothing in it is marked private. What is shown here is the public wall.

Line level, too. A file can be public with parts held back. The contract has an inline (agents-only: …) span for that, and the public copy does not carry it. One file is 1554 bytes at private and 1407 at public. Those 147 bytes are gone from the file, not hidden inside it.

Some things are in no copy at all. CLAUDE.md, .claude/, eval/, PENDING.md and README.md exist in the server's clone of your repo and in none of the three snapshots. The contract is infrastructure. Agents do not read it as content.

The agent is locked inside that directory

The reader agent runs with its working directory set to one snapshot. Not your repo. Not the server's clone. One tier's copy.

Four settings, on the runner every agent call goes through:

  • cwd is that tier's snapshot directory.
  • Read is allow-listed to that directory and nothing else.
  • permission_mode is dontAsk, so anything not pre-approved is denied.
  • 12 tools are denied by name: Bash, Write, Edit, MultiEdit, NotebookEdit, WebFetch, WebSearch, Task, Agent, TodoWrite, KillShell, BashOutput.

They are denied by name on purpose, so their schemas never enter the agent's context. An agent that cannot see a fetch tool cannot be talked into using one.

It also starts with no inherited configuration. No ~/.claude, and no .claude/ picked up from whatever directory it is standing in.

Your key does not carry a tier

The tier is not a field on the key. The server resolves it from a consumer profile attached to that key, and a key with no profile resolves to public.

Deny by default, in the lookup itself. A key created wrong is a weak key, never a powerful one.

Fed content is hostile input

Anything you feed in is text somebody else may have written. A page, a transcript, a comment. That text reaches an agent, so treat it as an attacker talking to your agent, because sometimes it is.

What the server does about it:

  • The fetch happens in ordinary server code, before any agent runs. The agent gets text, not a URL and not a network.
  • That agent has no network tools and no shell, the same lockdown as above.
  • The extractor runs against the minimum snapshot, never your private one.
  • Nothing lands in your brain from a proposal. It lands in an approval queue.
  • The queue renders a diff, and you press approve.

The residual risk is real and it does not go away. Injected text can still shape what gets proposed. It cannot commit. The human gate is the last control, which is why the approval screen shows you a diff instead of a summary.

Two credentials, and where they really live

The server reads your brain with one credential and writes to it with another. The read credential is an ed25519 deploy key the server generates, and you add the public half to your repo without write access. The write credential is a fine-grained token scoped to that one repository.

They are split because they fail differently. A stolen read key leaks your brain. A stolen write key rewrites it.

Now the part the design documents still get wrong. The write token used to be mounted into the worker container only, so the internet-facing container physically could not read it. The setup wizard changed that. Each credential now resolves in this order:

  1. an environment variable
  2. a file path from an environment variable
  3. an encrypted row in the database

The wizard writes the third one. So the token sits in the database, and any container with database access can read it. The product says so itself, in a comment in the file that resolves them.

You can have the old split back. Set the environment variable or the file path, and the database is never consulted. Worth doing if you are hardening a real deployment.

Values in that table are encrypted with Fernet, every one of them, through a single code path. In a running server the column holds this and nothing more:

ANTHROPIC_API_KEY            gAAAAABqb4Qe...  (228 chars)
BRAIN_GIT_SSH_PRIVATE_KEY    gAAAAABqb4QT...  (612 chars)
BRAIN_GIT_WRITE_PAT          gAAAAABqb4QY...  (204 chars)

Consumer keys work differently again. They are stored as a 64 character SHA-256 hash, and the plaintext is shown to you exactly once, at creation. No column anywhere holds the key itself. If you lose one you rotate it, because nobody can read it back to you.

The honest truths

Everything above is what the server does well. This is the rest.

Private notes are only as private as the box. Root on your VPS is root on your brain. Encryption at rest protects a stolen database dump. It does not protect a compromised host, because a running server has to decrypt in order to work.

Chat is stored in plain text. A chat message is an ordinary text column with no encryption on it. The settings table has an encrypted column. This one does not. Anyone holding your database holds your conversations.

Session transcripts are the sharpest edge here. The Claude CLI writes JSONL session files inside every container that runs an agent, and they hold note content in plain text. There is a command to expire them, and nothing runs it for you:

docker compose exec worker python manage.py cleanup_sdk_transcripts --days 7
removed 0 transcript file(s) older than 7d

Seven days is the default. The command exists, the scheduler entry does not, and a fresh server has zero scheduled tasks. Put it on a cron yourself. Until you do, private note content collects in those files and stays.

Losing one volume is unrecoverable. The brain-state volume holds the encryption key. Lose it and every stored credential becomes permanently unreadable, while the database survives with all of them intact and useless. Back that volume up with the database, not after it.

One thing the ledger gets right, since this section is otherwise a list of costs. The SDK operation log stores a hash of each prompt, not the prompt. Cost, model, token counts and timing are kept. The text is not.

The VPS itself is your job. SSH keys, a firewall, updates. This page is about the application.

Where to go next