Security
The boundaries, why each exists, and exactly what relaxing one costs.
The boundaries, why each exists, and exactly what relaxing one costs.
The read/write split
Two servers, two tokens, two bind addresses.
| Read server | Write server | |
|---|---|---|
| Binds | your mesh address | 127.0.0.1 only |
| Token | read-token.txt |
write-token.txt |
| Tools | search_notes, ask_*, code_search |
post_message, run_agent, agent_status, rate_answer |
The write server does not prefer loopback. It reads its bind address at
startup and calls SystemExit if that address is not a loopback address. A
config typo cannot silently publish write tools.
A property test proves this by starting the process with a wildcard bind and a valid token and reading the exit code. The missing-token case is the negative control. Without it, a refusal for an unrelated reason would look identical to the guard working.
Why the split is not paranoia
Every argument an MCP tool receives began as model output. Model output can be steered by anything the model has read: a web page, a note, a repository someone else wrote.
A read tool talked into misbehaving returns a wrong answer. A write tool talked into misbehaving posts, runs, or deletes. Splitting the credentials means the one that travels your network cannot act.
Reachability
The dashboard is served by the read server, and a browser cannot set an
Authorization header on a navigation. So it uses a session cookie:
brainlyy ui -> /?k=<read-token>
verified with compare_digest
HttpOnly, SameSite=Strict cookie set
302 to / (token leaves the URL bar)
ui.bind controls where that is reachable:
| Value | Meaning |
|---|---|
loopback (default) |
UI and /api/* served only to 127.0.0.1 |
mesh |
also served on your mesh address, so a phone on the same tailnet can reach it |
The MCP route is unaffected by this key, and a session cookie never
authenticates /mcp.
Logging in
brainlyy ui needs none of this on the machine itself, because it opens the
dashboard through a one-time token URL. The passphrase login exists for the
other case: opening the dashboard from a phone on the mesh, where pasting a
token into a URL bar leaves it in that browser's history for good. Set one
with brainlyy passphrase.
What is stored, and what is not:
passphrase.jsonholds an scrypt hash (n=2^15, r=8, p=1, a random 16-byte salt, a 32-byte key) plus those parameters, never the passphrase. scrypt is memory-hard: every guess against a stolen file costs ~32 MiB of RAM, not just CPU time.- A successful login sets an
HttpOnly,SameSite=Strictcookie holding a signed session: HMAC-SHA256 over{iat, exp, sub}under a secret generated intosession-secret.txt. Sessions lastui.session_days(default 14). Nothing is stored server-side; deletingsession-secret.txtsigns everyone out at once. - Five failures from one address lock it out for fifteen minutes, answering
HTTP 429 with
Retry-After, counted on a monotonic clock so sleep and clock steps cannot stretch or cancel it, and every failure and lockout is audited. The lockout refuses the correct passphrase too; the property suite proves that, because a "lockout" that keeps verifying is just a slower error message. - An unauthenticated
/redirects to/login;/api/*still answers401JSON so a fetch never follows a redirect into HTML;/mcpaccepts neither cookie, ever.
For a team, do not stretch this into multi-user auth. Put an OIDC-aware reverse proxy in front, such as oauth2-proxy, Cloudflare Access or Tailscale Serve, and let it own identity, MFA and revocation, which are entire products. This login exists because each of those needs an account, a third party, or a working network, and the pitch here is a tool that runs with the cable unplugged. One person, one machine, one passphrase, right-sized.
Write actions in the UI
The dashboard has three write endpoints, mounted with the rest of /api
behind the same session/bearer gate:
| Endpoint | Body | Forwarded to |
|---|---|---|
POST /api/agent/run |
{"agent": "<name>"} (optional "prompt") |
run_agent |
POST /api/rate |
{"id": "<id>", "score": <int>, "note": "<text>"} |
rate_answer |
POST /api/post |
{"channel": "<name>", "text": "<text>"} |
post_message |
None of them act. Each is proxied to the write server at
127.0.0.1:<ports.write> with the write token attached inside the proxy, so
everything past that hop, the loopback bind, the token check and the remote
allowlists, is enforced exactly as it would be for a local caller. The UI
decides whether to forward; what a write may do is decided on the far side,
where the browser's credential grants nothing.
Whether to forward is judged on the address the connection arrived on,
scope["server"], the door. It is never judged on the client address, which
says who is asking rather than which door they came through. A connection that arrived on
127.0.0.1 may write. Any other door needs ui.remote_writes: true.
/api/status.writes_enabled is computed by the same function that gates the
endpoints, so the dashboard renders write controls only where the server would
accept the write. Absent controls, not disabled ones, because a disabled button
still tells you the endpoint exists.
Every answer on this path is distinguishable, on purpose:
| Status | Meaning |
|---|---|
401 |
no session cookie or bearer; the /api gate, inherited |
403 |
the connection did not arrive on loopback and ui.remote_writes is false; the body names that key |
400 |
the body is not the JSON the endpoint documents |
503 |
the write server is not running; an absent capability, not a crash, and the body says which process to start |
502 |
the write server answered but refused the call or could not be understood |
405 |
any method but POST; a write a GET can reach is a write a prefetcher can make |
200 |
the write server ran the tool; the body carries its output |
ui.remote_writes: true lifts the 403. Turning it on means:
- a session cookie captured from your phone can run an agent or post a message
- the practical value of the read/write split drops to the strength of one cookie, because the mesh-reachable surface can now act
- the write server's own guard is still intact, since writes are proxied to it over loopback with its own token, but the thing deciding whether to proxy is now network-facing
It is off by default for those reasons. If you turn it on, prefer a tailnet with device approval, and rotate the read token if a device is lost.
Secrets
Secrets never appear in config. init generates them into separate files under
~/.brainlyy (or BRAINLYY_HOME), so a config can be committed and shared while
credentials cannot. Both live outside the install, so upgrading never touches
them.
Rotating a token is rm ~/.brainlyy/read-token.txt && brainlyy init, then
re-register with your MCP client.
The public edge (tier 4)
If you expose the read server publicly, source-restrict the port to your assistant provider's published egress range. Reachability beats authentication: a request that cannot arrive cannot be replayed, brute-forced, or scanned.
Use HTTP-01 for ACME, never TLS-ALPN-01. TLS-ALPN-01 validates over 443, which your allowlist blocks. Renewal then fails silently, about sixty days after issuance.
Reporting a vulnerability
Open a private security advisory on the repository. Do not open a public issue.