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:
cwdis that tier's snapshot directory.Readis allow-listed to that directory and nothing else.permission_modeisdontAsk, 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:
- an environment variable
- a file path from an environment variable
- 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.